mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 17:17:14 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cba842fbee | |||
| a61b0f0068 | |||
| c15f668126 | |||
| ec63978ecf | |||
| 1cfe87186c | |||
| 9794df0933 | |||
| a63808caa6 | |||
| de3c0b00ad | |||
| cb45807b12 | |||
| d942bfe19a |
@@ -1,5 +1,5 @@
|
||||
name: 漏洞反馈
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://langbot.app/docs/zh/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
@@ -22,7 +22,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 异常情况
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
description: 完整描述异常情况,什么时候发生的、发生了什么。**请附带日志信息。**
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Bug report
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://langbot.app/docs/en/workshop/network-details
|
||||
title: "[Bug]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -43,8 +43,8 @@ Run the narrowest useful test first, then broader checks when confidence is need
|
||||
## Where to Look
|
||||
|
||||
- Architecture map: `ARCHITECTURE.md`.
|
||||
- Dev environment guide: https://docs.langbot.app/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://docs.langbot.app/zh/develop/plugin-runtime.
|
||||
- Dev environment guide: https://langbot.app/docs/zh/develop/dev-config.
|
||||
- Plugin runtime / CLI / SDK debugging: https://langbot.app/docs/zh/develop/plugin-runtime.
|
||||
- API-key auth: `docs/API_KEY_AUTH.md`.
|
||||
- Box deep-dive notes: `docs/review/box-architecture.md` and related files.
|
||||
- In-repo skills: `skills/` is the single source of truth for LangBot agent skills.
|
||||
|
||||
+16
-7
@@ -169,14 +169,17 @@ The Plugin Runtime supports stdio and WebSocket control transports. Direct local
|
||||
|
||||
## Box Runtime and Skills
|
||||
|
||||
Box is the sandbox subsystem used by native agent tools, stdio MCP servers, skill authoring, and managed processes.
|
||||
Box is the optional sandbox subsystem used by native execution tools, stdio MCP servers, agent-side skill authoring, and managed processes. Skill storage and read-only access are Core responsibilities and remain available without Box.
|
||||
|
||||
In this repo:
|
||||
|
||||
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, skill CRUD, status, reconnects, quotas, mounts, and sandbox profiles.
|
||||
- `pkg/box/service.py` is the application-facing facade for exec, sessions, managed processes, status, reconnects, quotas, generic mounts, and sandbox profiles.
|
||||
- `pkg/box/connector.py` connects to the Box Runtime over stdio, Windows subprocess+WebSocket, or remote WebSocket.
|
||||
- `pkg/provider/tools/loaders/native.py`, `mcp_stdio.py`, and skill loaders depend on Box availability.
|
||||
- `pkg/skill/manager.py` loads skills from the Box runtime, falling back to local `data/skills` when needed.
|
||||
- `pkg/provider/tools/loaders/native.py` is the Core orchestration seam: the
|
||||
Skill loader supplies generic read-only mounts to Box execution. `mcp_stdio.py`
|
||||
and execution-backed tools depend on Box availability.
|
||||
- `pkg/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; `skills.root` owns its location independently of Box.
|
||||
- `pkg/skill/manager.py` caches the Core repository catalog for progressive disclosure. Activation and read-only resource tools do not require Box; script execution and Workspace mutation still do.
|
||||
|
||||
Durable Box Workspace storage is shared across placement generations, but
|
||||
sandbox sessions and managed processes are generation-scoped. LangBot validates
|
||||
@@ -187,11 +190,17 @@ retires stale processes and closes already-attached relays.
|
||||
In `langbot-plugin-sdk`:
|
||||
|
||||
- `src/langbot_plugin/box/server.py` implements `lbp box` and the WebSocket endpoints on `:5410`.
|
||||
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions and managed processes.
|
||||
- `src/langbot_plugin/box/runtime.py` owns sandbox sessions, generic read-only mounts, and managed processes.
|
||||
- `backend.py`, `nsjail_backend.py`, and `e2b_backend.py` implement sandbox backends.
|
||||
- `skill_store.py` manages skill packages from the Box side.
|
||||
- `src/langbot_plugin/skill_store.py` is consumed by Core, not Box. Core turns
|
||||
selected package roots into generic read-only mounts; Box does not understand
|
||||
Skill names, metadata, revisions, files, or CRUD.
|
||||
|
||||
Important config keys live under `box:` in `src/langbot/templates/config.yaml`: `box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. Start LangBot with `--standalone-box` when connecting to an externally launched Box runtime.
|
||||
Skill storage uses `skills.root`. Box execution config lives under `box:`:
|
||||
`box.enabled`, `box.backend`, `box.runtime.endpoint`, and `box.local.*`. The old
|
||||
`box.local.skills_root` key is read only as an online-upgrade fallback and is
|
||||
marked for removal in the next major version. Start LangBot with
|
||||
`--standalone-box` when connecting to an externally launched Box runtime.
|
||||
|
||||
## HTTP API, Web UI, and MCP Server
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Website</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Features</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Docs</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||
@@ -49,7 +49,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||
|
||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||
[→ Learn more about all features](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**More options:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +151,7 @@ _Note: Public demo environment. Do not enter sensitive information._
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||
|
||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||
[→ View all integrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官网</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文档</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||
<a href="https://space.langbot.app">扩展市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
@@ -49,7 +49,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -89,7 +89,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手动部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [宝塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -152,7 +152,7 @@ docker compose --profile all up -d
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整集成列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Características</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentación</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||
|
||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||
[→ Conocer más sobre todas las funcionalidades](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Más opciones:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manual](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||
|
||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||
[→ Ver todas las integraciones](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Fonctionnalités</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Documentation</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||
[→ En savoir plus sur toutes les fonctionnalités](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Plus d'options :** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Manuel](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||
|
||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||
[→ Voir toutes les intégrations](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/features">機能</a> |
|
||||
<a href="https://langbot.app/docs/ja/insight/guide">ドキュメント</a> |
|
||||
<a href="https://langbot.app/docs/ja/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||
|
||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||
[→ すべての機能について詳しく見る](https://langbot.app/docs/ja/insight/features)
|
||||
|
||||
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**その他:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [手動デプロイ](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||
|
||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||
[→ すべての統合を表示](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">기능</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">문서</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||
|
||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 기능 자세히 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**더 많은 옵션:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [수동 배포](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||
|
||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||
[→ 모든 통합 보기](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Возможности</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Документация</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||
|
||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||
[→ Подробнее обо всех возможностях](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Другие варианты:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Ручная установка](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||
|
||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||
[→ Смотреть все интеграции](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官網</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/features">特性</a> |
|
||||
<a href="https://langbot.app/docs/zh/insight/guide">文件</a> |
|
||||
<a href="https://langbot.app/docs/zh/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
@@ -50,7 +50,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
[→ 了解更多功能特性](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||
|
||||
@@ -90,7 +90,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/zh/deploy/langbot/kubernetes)
|
||||
**更多方式:** [Docker](https://langbot.app/docs/zh/deploy/langbot/docker) · [手動部署](https://langbot.app/docs/zh/deploy/langbot/manual) · [寶塔面板](https://langbot.app/docs/zh/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/zh/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +165,7 @@ docker compose --profile all up -d
|
||||
|-----------|------|
|
||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||
|
||||
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||
[→ 查看完整整合列表](https://langbot.app/docs/zh/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Trang chủ</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/features">Tính năng</a> |
|
||||
<a href="https://langbot.app/docs/en/insight/guide">Tài liệu</a> |
|
||||
<a href="https://langbot.app/docs/en/tags/readme">API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||
|
||||
@@ -48,7 +48,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||
[→ Tìm hiểu thêm về tất cả tính năng](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||
|
||||
@@ -88,7 +88,7 @@ docker compose --profile all up -d
|
||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||
|
||||
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](https://docs.langbot.app/en/deploy/langbot/kubernetes)
|
||||
**Thêm tùy chọn:** [Docker](https://langbot.app/docs/en/deploy/langbot/docker) · [Thủ công](https://langbot.app/docs/en/deploy/langbot/manual) · [BTPanel](https://langbot.app/docs/en/deploy/langbot/one-click/bt) · [Kubernetes](https://langbot.app/docs/en/deploy/langbot/kubernetes)
|
||||
|
||||
---
|
||||
|
||||
@@ -149,7 +149,7 @@ docker compose --profile all up -d
|
||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||
|
||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||
[→ Xem tất cả tích hợp](https://langbot.app/docs/en/insight/features)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Docker Compose configuration for LangBot
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://docs.langbot.app
|
||||
# For Kubernetes deployment, see kubernetes.yaml and the deployment guide at https://langbot.app/docs
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
@@ -27,8 +27,8 @@ services:
|
||||
|
||||
# The Box sandbox runtime is optional. It is only started when you run
|
||||
# ``docker compose --profile box up`` (or ``docker compose --profile all
|
||||
# up``). With Box off, LangBot keeps the dashboard / skills list visible
|
||||
# (read-only) but disables sandbox tools, skill add/edit and stdio MCP —
|
||||
# up``). With Box off, LangBot keeps skill management, activation, and
|
||||
# read-only resources available but disables execution tools and stdio MCP —
|
||||
# set ``box.enabled: false`` in ``data/config.yaml`` (or
|
||||
# ``BOX__ENABLED=false`` in the langbot service env below) to match.
|
||||
langbot_box:
|
||||
@@ -73,6 +73,10 @@ services:
|
||||
container_name: langbot
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Core owns the SkillRepository even when the Box profile is disabled.
|
||||
# Keep this path identical to langbot_box so optional execution can
|
||||
# consume the same Workspace-scoped package revisions.
|
||||
- ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
@@ -92,7 +96,9 @@ services:
|
||||
# box.* and are forwarded to the Box runtime via INIT RPC.
|
||||
- BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
- BOX__LOCAL__DEFAULT_WORKSPACE=default
|
||||
- BOX__LOCAL__SKILLS_ROOT=skills
|
||||
# TODO(next-major): default LANGBOT_SKILLS_ROOT to ${PWD}/data/skills
|
||||
# after the historical Box storage path no longer needs zero-copy upgrades.
|
||||
- SKILLS__ROOT=${LANGBOT_SKILLS_ROOT:-${LANGBOT_BOX_ROOT:-${PWD}/data/box}/skills}
|
||||
- BOX__LOCAL__ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box}
|
||||
- BOX__DOCKER__CPU_LIMIT_ENABLED=${LANGBOT_BOX_DOCKER_CPU_LIMIT_ENABLED:-true}
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kubernetes Deployment for LangBot
|
||||
# This file provides Kubernetes deployment manifests for LangBot based on docker-compose.yaml
|
||||
#
|
||||
# Full deployment guide (zh/en/ja): https://docs.langbot.app -> Installation -> Kubernetes
|
||||
# Full deployment guide (zh/en/ja): https://langbot.app/docs -> Installation -> Kubernetes
|
||||
#
|
||||
# Usage:
|
||||
# kubectl -n langbot create secret generic langbot-plugin-runtime-control \
|
||||
@@ -214,8 +214,9 @@ spec:
|
||||
# Deployment for LangBot Box (sandbox) runtime
|
||||
#
|
||||
# The Box runtime backs LangBot's sandbox tools (exec / read / write / edit /
|
||||
# glob / grep), the `activate` skill tool, skill add/edit, and stdio-mode MCP
|
||||
# servers. It is OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# glob / grep), Skill script execution, and stdio-mode MCP servers. Skill
|
||||
# activation, resources, and management remain Core-owned without Box. Box is
|
||||
# OPTIONAL: if you do not deploy it, set `BOX__ENABLED=false` on
|
||||
# the langbot Deployment (or `box.enabled: false` in config.yaml) so the
|
||||
# dashboard renders cleanly with sandbox features disabled.
|
||||
#
|
||||
@@ -448,14 +449,15 @@ spec:
|
||||
key: token
|
||||
# box.local.* config — forwarded to the Box runtime via INIT RPC. The
|
||||
# host_root MUST match the box-root hostPath mountPath below AND the box
|
||||
# Deployment's box-root mountPath, so that skill package paths resolve
|
||||
# identically on both sides and on the node's Docker daemon.
|
||||
# Deployment's box-root mountPath, so generic package mount paths
|
||||
# resolve identically on both sides and on the node's Docker daemon.
|
||||
- name: BOX__LOCAL__HOST_ROOT
|
||||
value: "/app/data/box"
|
||||
- name: BOX__LOCAL__DEFAULT_WORKSPACE
|
||||
value: "default"
|
||||
- name: BOX__LOCAL__SKILLS_ROOT
|
||||
value: "skills"
|
||||
- name: SKILLS__ROOT
|
||||
# TODO(next-major): use /app/data/skills after the legacy path window.
|
||||
value: "/app/data/box/skills"
|
||||
- name: BOX__LOCAL__ALLOWED_MOUNT_ROOTS
|
||||
value: "/app/data/box"
|
||||
volumeMounts:
|
||||
|
||||
@@ -218,8 +218,8 @@ metadata:
|
||||
spec:
|
||||
categories: [popular, global]
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
config:
|
||||
- { name: inbound_secret, type: string, required: true, default: "" }
|
||||
- { name: callback_url, type: string, required: false, default: "" }
|
||||
|
||||
@@ -243,7 +243,7 @@ For large datasets:
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
- LangBot Documentation: https://langbot.app/docs
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
│ │ │ (shared 容器, 多 process) │
|
||||
│ │ │ │
|
||||
│ │ ├──> SkillToolLoader (activate 工具) │
|
||||
│ │ │ └─ build_execution_mounts() │
|
||||
│ │ │ │
|
||||
│ │ ├──> SkillAuthoringToolLoader │
|
||||
│ │ │ │
|
||||
@@ -33,7 +34,7 @@
|
||||
│ ├─ Workspace quota 检查 │
|
||||
│ ├─ 输出截断 (head+tail) │
|
||||
│ ├─ Session ID 模板解析 (resolve_box_session_id) │
|
||||
│ ├─ 技能挂载组装 (build_skill_extra_mounts) │
|
||||
│ ├─ 通用只读挂载接收 (read_only_mounts) │
|
||||
│ ├─ 重连循环 (_reconnect_loop, 指数退避) │
|
||||
│ └─ BoxRuntimeConnector │
|
||||
│ ├─ 心跳 loop (20s ping) │
|
||||
@@ -41,7 +42,7 @@
|
||||
│ │ Action RPC (stdio 或 WebSocket) │
|
||||
│ │
|
||||
│ SkillManager (skill_mgr) │
|
||||
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
||||
│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -59,10 +60,8 @@
|
||||
│ NsjailBackend ──┘ (本地 CLI 或 fallback 到容器内 CLI) │
|
||||
│ E2BBackend (云沙箱, 需要 E2B_API_KEY) │
|
||||
│ │
|
||||
│ BoxSkillStore │
|
||||
│ ├─ list / get / create / update / delete │
|
||||
│ ├─ scan_skill_directory / read_skill_file / write_skill_file │
|
||||
│ └─ preview_skill_zip / install_skill_zip (zip 或 GitHub) │
|
||||
│ Generic mount admission │
|
||||
│ └─ allow-list + read-only + normalized target validation │
|
||||
│ │
|
||||
│ aiohttp 单端口服务 (默认 :5410): │
|
||||
│ /rpc/ws — Action RPC │
|
||||
@@ -85,7 +84,7 @@
|
||||
**核心设计原则**:
|
||||
- Box Runtime 作为独立进程运行,通过 Action RPC 与 LangBot 主进程通信,两者复用 SDK 的 IO 层(Handler → Connection → Controller)
|
||||
- 一个 session_id 对应一个容器/沙箱实例。同一 session 内可并存多条 mount 与多个 managed process
|
||||
- Skill / 默认 exec / MCP Server 共享同一个 session 容器(详见 [box-session-scope.md](./box-session-scope.md))
|
||||
- Skill 仅是 Core 组装 mount 的业务来源;Box 与默认 exec / MCP Server 只共享通用 session 和 mount 机制(详见 [box-session-scope.md](./box-session-scope.md))
|
||||
|
||||
---
|
||||
|
||||
@@ -93,7 +92,7 @@
|
||||
|
||||
### 2.1 BoxService (`pkg/box/service.py`, 722 行)
|
||||
|
||||
应用层门面,协调 Profile、安全校验、配额、连接、Skill 挂载与 Session 模板:
|
||||
应用层门面,协调 Profile、安全校验、配额、连接、Core 生成的只读挂载与 Session 模板:
|
||||
|
||||
主要公开方法(按定义顺序):
|
||||
|
||||
@@ -105,7 +104,7 @@ BoxService
|
||||
├─ available (property) 连接状态
|
||||
│
|
||||
├─ resolve_box_session_id(query) 从 pipeline 模板解析 session_id
|
||||
├─ build_skill_extra_mounts(query) 组装 pipeline-bound skill 的挂载列表
|
||||
├─ execute_tool(..., read_only_mounts=...) 接收 Core 组装的通用只读挂载
|
||||
│
|
||||
├─ execute_tool(parameters, query) Agent 调用 exec 时的入口
|
||||
│ ├─ _apply_profile / build_spec
|
||||
@@ -122,12 +121,6 @@ BoxService
|
||||
├─ stop_managed_process(session_id, pid) 单独停止某个 managed process
|
||||
├─ get_managed_process_websocket_url(...) 返回 WS attach URL
|
||||
│
|
||||
├─ list_skills() / get_skill(name) Skill 元数据
|
||||
├─ create_skill / update_skill / delete_skill Skill CRUD
|
||||
├─ scan_skill_directory(path) 扫描目录
|
||||
├─ list_skill_files / read_skill_file / write_skill_file
|
||||
├─ preview_skill_zip / install_skill_zip zip / GitHub 安装
|
||||
│
|
||||
├─ shutdown() / dispose() 清理:RPC SHUTDOWN + 进程终止
|
||||
├─ get_status() / get_sessions() / get_recent_errors()
|
||||
└─ get_system_guidance() LLM 系统提示
|
||||
@@ -137,7 +130,7 @@ BoxService
|
||||
|
||||
**输出截断**: 默认 4000 字符上限,保留前 60% + 后 40%,中间插入 `[...truncated...]`。
|
||||
|
||||
**Skill 挂载合并**: `execute_tool()` 调用时,`build_skill_extra_mounts(query)` 会把当前 pipeline-bound 的所有 skill 的 `package_root` 作为 `extra_mounts` 加入 BoxSpec,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径。
|
||||
**Skill 挂载合并**: native loader 调用 `skill.build_execution_mounts()`,把当前 pipeline-bound 的所有 skill 的 `package_root` 转成普通只读 mount,再通过 `BoxService.execute_tool(..., read_only_mounts=...)` 交给 Box,挂在 `/workspace/.skills/<name>`。LLM 通过 `activate` 工具显式激活某个 skill 后,工具调用才允许引用这个 skill 的虚拟路径;BoxService 和 Box Runtime 都不知道这些 mount 来自 Skill。
|
||||
|
||||
### 2.2 BoxRuntimeConnector (`pkg/box/connector.py`, 357 行)
|
||||
|
||||
@@ -171,11 +164,10 @@ BoxService
|
||||
```
|
||||
SkillManager
|
||||
├─ initialize() 调用 reload_skills()
|
||||
├─ reload_skills() 先从 Box runtime list_skills(),
|
||||
│ 不可用则回落 data/skills/ 扫描
|
||||
├─ refresh_skill_from_disk() 单 skill 重新加载
|
||||
├─ reload_skills() 从 Core SkillRepository 加载
|
||||
├─ refresh_skill_from_disk() 检查单 skill 的缓存状态
|
||||
├─ get_skill_by_name(name)
|
||||
└─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径
|
||||
└─ build_skill_aware_prompt_addition() 生成渐进披露索引
|
||||
```
|
||||
|
||||
skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。
|
||||
@@ -295,7 +287,7 @@ start_managed_process(session, spec):
|
||||
|
||||
单端口 aiohttp 服务(默认 5410),通过路径区分(commit `8c71ec5` 合并端口):
|
||||
|
||||
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理所有 action,包括 `INIT` 配置注入、skill store 操作等
|
||||
1. **Action RPC** (`/rpc/ws`): `BoxServerHandler` 处理 `INIT`、exec、session、managed-process 与状态等通用 action
|
||||
2. **WS Relay** (`/v1/sessions/{id}/managed-process/ws` 与 `/v1/sessions/{id}/managed-process/{pid}/ws`): 双向桥接 WebSocket ↔ 指定 managed process stdin/stdout
|
||||
|
||||
stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process attach;Action RPC 走 stdin/stdout。
|
||||
@@ -304,7 +296,7 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
||||
|
||||
`ActionRPCBoxClient` 封装 `Handler.call_action()` 调用:
|
||||
|
||||
- 25+ 方法对应 25+ 个 RPC action(exec / session / managed-process / skill / status / shutdown)
|
||||
- 方法对应 exec / session / managed-process / status / shutdown 等通用 RPC action,不暴露 Skill CRUD
|
||||
- 错误还原: `_translate_action_error()` 通过字符串前缀匹配还原 SDK 侧异常类型
|
||||
- `execute()` timeout = 300s,其他默认 15s
|
||||
- `BoxRuntimeClient` 是 ABC,供后续可能的非 RPC 实现复用
|
||||
@@ -343,23 +335,32 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta
|
||||
|
||||
`BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。
|
||||
|
||||
### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行)
|
||||
### 3.7 SkillStore (`langbot_plugin.skill_store`)
|
||||
|
||||
新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime:
|
||||
Skill 包存储最初位于 Box Runtime;issue #2410 将通用实现抽到 Plugin SDK 顶层,由 Core 独占存储和 revision 语义:
|
||||
|
||||
```
|
||||
BoxSkillStore
|
||||
SkillStore
|
||||
├─ list_skills() / get_skill(name)
|
||||
├─ create_skill(data) / update_skill(name, data) / delete_skill(name)
|
||||
├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表
|
||||
├─ list_skill_files(name, path) 浏览 skill 内文件树
|
||||
├─ read_skill_file(name, path) / write_skill_file(name, path, content)
|
||||
├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源
|
||||
├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源
|
||||
├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口
|
||||
├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容
|
||||
└─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root
|
||||
└─ 支持 source_subdir / target_suffix(commit 1aa043f)
|
||||
```
|
||||
|
||||
GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。
|
||||
GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件位于独立的 `skills.root`,执行时由 Core 组装成通用只读 `BoxMountSpec` 并挂载到 `/workspace/.skills/`。Box 的正常模型、客户端和 Runtime 不包含 `skill_name`、Skill CRUD、revision 或 `SKILL.md` 语义。
|
||||
|
||||
滚动升级只保留一个隔离桥:`box/legacy_skill_compat.py` 让旧 Core 暂时调用新 Box,并把旧 `skill_name` 转为普通只读 mount。部署顺序必须先升级 Box、再升级 Core。该模块有 `TODO(next-major)`,下一大版本删除;正常架构不依赖它。
|
||||
|
||||
下一大版本的删除清单(当前均有 `TODO(next-major)`):
|
||||
|
||||
1. 删除 SDK `box/legacy_skill_compat.py` 及 Server 中唯一的注册/转换钩子。
|
||||
2. 删除 Core 对 `box.local.skills_root` 的配置 fallback。
|
||||
3. 新安装默认从历史 `./data/box/skills` 切到 `./data/skills`;届时 Box 部署只需只读访问 Core 明确下发的通用 artifact root。
|
||||
|
||||
### 3.8 Security (`box/security.py`, 52 行)
|
||||
|
||||
@@ -463,7 +464,7 @@ BuildAppStage.run(ap)
|
||||
├─ ap.tool_mgr = tool_mgr
|
||||
│
|
||||
├─ ... (platform, pipeline) ...
|
||||
├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表)
|
||||
├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表)
|
||||
└─ ... (RAG, HTTP, plugins) ...
|
||||
```
|
||||
|
||||
@@ -480,7 +481,7 @@ except Exception as e:
|
||||
logger.warning(f"Box runtime unavailable: {e}")
|
||||
```
|
||||
|
||||
**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||
**静默降级**: Box 初始化失败不会阻止应用启动。6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM;`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。
|
||||
|
||||
### 5.3 销毁流程
|
||||
|
||||
@@ -504,13 +505,16 @@ Box 额外做了 RPC SHUTDOWN 通知 Runtime 主动清理容器,比 Plugin 的
|
||||
### config.yaml (重构后)
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
root: './data/box/skills' # Core-owned;Box 关闭时仍可管理/读取
|
||||
|
||||
box:
|
||||
enabled: true # 整个 Box 子系统的总开关。设为 false 时:
|
||||
# - 不连接远程 Box runtime,不 fork 本地 stdio 子进程
|
||||
# - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM
|
||||
# - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝
|
||||
# - Agent 从 sandbox 注册 skill 的能力不可用
|
||||
# - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响)
|
||||
# - skill 列表/读取保持只读可用
|
||||
# - skill 管理、激活和只读资源保持可用
|
||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||
# 由 box.backend / BOX__BACKEND 选择后端
|
||||
@@ -522,7 +526,6 @@ box:
|
||||
image: '' # 覆盖 profile 默认 image
|
||||
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||
default_workspace: '' # 默认 '<host_root>/default'
|
||||
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
||||
allowed_mount_roots: # 默认 ['<host_root>']
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
@@ -561,16 +564,17 @@ volumes:
|
||||
| 消费方 | Box 可用 | Box 不可用(disabled 或 failed) |
|
||||
|---|---|---|
|
||||
| native exec/read/write/edit/glob/grep 工具 | 暴露给 LLM | **不暴露** |
|
||||
| `activate` / `register_skill` 工具 | 暴露给 LLM | **不暴露** |
|
||||
| `activate` / Skill resource 工具 | 暴露给 LLM | 暴露给 LLM |
|
||||
| `register_skill` 工具 | 暴露给 LLM | **不暴露**;直接调用返回 `sandbox_unavailable` |
|
||||
| stdio MCP server | 在 Box 内启动 | **`_init_stdio_python_server` 抛 RuntimeError** 拒绝;不退化到宿主 stdio |
|
||||
| http/sse MCP server | 正常 | 正常(不依赖 Box) |
|
||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Box runtime | 走 LangBot 本地 `data/skills/` 只读 fallback |
|
||||
| Skill 创建/编辑/安装/写文件 | 走 Box runtime | **HTTP 400** + 明确错误信息(`_require_box_for_write`) |
|
||||
| Skill 列表/读取 (`list_skills`/`get_skill`/`read_skill_file`) | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||
| Skill 创建/编辑/安装/写文件 | 走 Core SkillRepository | 走 Core SkillRepository |
|
||||
| Pipeline AI 配置中 `box-session-id-template` | 正常生效 | **前端 banner** 提示字段无效 |
|
||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | **前端禁用** + banner |
|
||||
| Pipeline 扩展页 `enable_all_skills` / 绑定 skill | 可编辑 | 可编辑 |
|
||||
| 仪表盘 Box 状态卡片 | 绿点 / "已连接" | 灰点 / "已禁用"(disabled) 或 红点 / "已断开"(failed) |
|
||||
|
||||
> 后端拒写的边界条件:如果 `ap.box_service` **完全没装**(老式 dev mode,没经过 BuildAppStage),`_require_box_for_write` 视作 no-op,保留 `data/skills/` 本地路径——以兼容历史测试与最小化设置。生产环境总会装 `ap.box_service`,因此该 fallback 不会被触发。
|
||||
> Core 的 SkillRepository 是 `langbot_plugin.skill_store.SkillStore` 的异步 Workspace 适配层。默认 `skills.root` 保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill;旧 `box.local.skills_root` 仅作为在线升级 fallback,并将在下一大版本删除。Box 只消费 Core 下发的通用只读 mount。
|
||||
|
||||
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
### S5. 挂载校验缺口 — Med-High
|
||||
|
||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
||||
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
||||
- **现状**: grant-enforced 模式已经由 Core 与 Runtime 双重校验通用只读 mount(绝对路径 allow-list、存在性、只读模式、规范化且位于 `/workspace` 下的目标);它不再有 Skill 特例。遗留风险仅在 admission-disabled 的低信任直连场景:通用 `extra_mounts` 仍未统一套用 grant-enforced 白名单。
|
||||
- **要求**: admission-disabled 的外部控制面也复用同一套通用 mount 校验;SDK 黑名单加入 `/`(或全面改白名单)。
|
||||
|
||||
### S6. 容器加固缺失 — Med
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ has shipped the design largely as written:
|
||||
| Docker / nsjail / E2B backends apply extra mounts | ✅ Shipped | Last gap closed by SDK commit `0fea9b1` (E2B) |
|
||||
| `box-session-id-template` in `local-agent` pipeline config | ✅ Shipped | `templates/metadata/pipeline/ai.yaml`, default `{launcher_type}_{launcher_id}` |
|
||||
| `BoxService.resolve_box_session_id(query)` | ✅ Shipped | `pkg/box/service.py:166` |
|
||||
| `BoxService.build_skill_extra_mounts(query)` | ✅ Shipped | `pkg/box/service.py:189` |
|
||||
| `skill.build_execution_mounts(ap, query)` | ✅ Shipped | Core composes read-only packages; Box receives generic mounts |
|
||||
| Skill exec uses unified container + extra mounts | ✅ Shipped | `pkg/provider/tools/loaders/native.py` skill branch |
|
||||
| MCP-in-Box uses shared persistent session, multi-process | ✅ Shipped (earlier than originally scoped) | SDK commit `529088e`, LangBot `mcp_stdio.py:_build_box_session_id` |
|
||||
| `BoxManagedProcessSpec.process_id` + multi-process per session | ✅ Shipped | `BoxRuntime` keeps `managed_processes: dict[pid, _ManagedProcess]` |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
| BoxService workspace quota | 优秀 | 前置/后置配额检查、超额清理 |
|
||||
| BoxService 输出截断 | 优秀 | 短/精确边界/长输出、独立 stderr |
|
||||
| BoxService 可观测性 | 优秀 | 状态报告、error ring buffer、buffer 上限 |
|
||||
| BoxService session 模板 | 良好 | `resolve_box_session_id` + `build_skill_extra_mounts` 在 service / native / mcp 三处都有覆盖 |
|
||||
| BoxService session / mount contract | 良好 | `resolve_box_session_id` + generic `read_only_mounts`; Skill mount composition is covered in the Core loader |
|
||||
| RPC client/server 协议 | 优秀 | execute/get_sessions/delete/create/conflict error |
|
||||
| BoxRuntimeConnector | 良好 | local/remote 模式、Docker 平台、relay URL、心跳与重连回调 |
|
||||
| BoxWorkspaceSession | 良好 | payload 构建、managed process 路径重写、stage host file |
|
||||
|
||||
@@ -6,7 +6,7 @@ Minimal, dependency-light clients for the LangBot **HTTP Bot** platform adapter.
|
||||
They show the whole loop: signing a request, pushing a message, and receiving
|
||||
multi-part replies on a callback endpoint.
|
||||
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://docs.langbot.app/en/usage/platforms/http-bot).
|
||||
Full guide: [docs.langbot.app — HTTP Bot](https://langbot.app/docs/en/usage/platforms/http-bot).
|
||||
Machine-readable contract: [`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json).
|
||||
|
||||
## Files
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
它们完整展示了整条链路:对请求签名、推送一条消息、在回调端点接收
|
||||
1→M 的多段回复。
|
||||
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://docs.langbot.app/zh/usage/platforms/http-bot)。
|
||||
完整指南:[docs.langbot.app —— HTTP Bot](https://langbot.app/docs/zh/usage/platforms/http-bot)。
|
||||
机器可读的接口契约:[`docs/http-bot-openapi.json`](../../docs/http-bot-openapi.json)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
@@ -6,7 +6,7 @@ A single self-contained HTML page that demos the LangBot **Page Bot**
|
||||
(`web_page_bot`) embeddable chat widget — the one you drop onto any website with
|
||||
a single `<script>` tag.
|
||||
|
||||
Full guide: [docs.langbot.app — Page Bot](https://docs.langbot.app/en/usage/platforms/webpage).
|
||||
Full guide: [docs.langbot.app — Page Bot](https://langbot.app/docs/en/usage/platforms/webpage).
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
(`web_page_bot`) 的可嵌入聊天组件 —— 也就是你用一行 `<script>` 标签就能放到任意
|
||||
网站上的那个组件。
|
||||
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://docs.langbot.app/zh/usage/platforms/webpage)。
|
||||
完整指南:[docs.langbot.app —— 页面机器人](https://langbot.app/docs/zh/usage/platforms/webpage)。
|
||||
|
||||
## 文件清单
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.9"
|
||||
version = "4.10.10"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.5.6",
|
||||
"langbot-plugin==0.5.7",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -114,7 +114,7 @@ seekdb = [
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://langbot.app"
|
||||
Documentation = "https://docs.langbot.app"
|
||||
Documentation = "https://langbot.app/docs"
|
||||
Repository = "https://github.com/langbot-app/LangBot"
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -48,7 +48,7 @@ tools, skill add/edit, and stdio MCP are disabled. Set `box.enabled: false`
|
||||
## Kubernetes
|
||||
|
||||
See `docker/kubernetes.yaml` and the deployment guide at
|
||||
https://docs.langbot.app. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
https://langbot.app/docs. `docker/deploy-k8s-test.sh` is a test helper.
|
||||
|
||||
## config.yaml (generated at `data/config.yaml` on first run)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ asciiart = r"""
|
||||
|___/
|
||||
|
||||
⭐️ Open Source 开源地址: https://github.com/langbot-app/LangBot
|
||||
📖 Documentation 文档地址: https://docs.langbot.app
|
||||
📖 Documentation 文档地址: https://langbot.app/docs
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .. import group
|
||||
@@ -24,12 +21,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills(request_context)
|
||||
except EntitlementFeatureUnavailableError:
|
||||
# Plans without managed sandbox support have no runnable skills.
|
||||
# Treat that capability absence as an empty collection so the
|
||||
# shared UI can render normally instead of surfacing a 500.
|
||||
return self.success(data={'skills': []})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@@ -47,7 +39,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.create_skill(request_context, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -59,7 +51,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
@@ -77,13 +69,13 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -105,7 +97,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -118,7 +110,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -136,7 +128,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -170,7 +162,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -194,7 +186,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -219,7 +211,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
source_paths=form.getlist('source_paths'),
|
||||
)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -242,7 +234,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
filename=file.filename or '',
|
||||
)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -261,5 +253,5 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@@ -137,7 +137,16 @@ class BotService:
|
||||
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception:
|
||||
# The bot row was already inserted above; without this rollback a
|
||||
# failing adapter constructor (e.g. a missing optional credential
|
||||
# key) would leave a permanently disabled orphan bot in the DB.
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_data['uuid'])
|
||||
)
|
||||
raise
|
||||
|
||||
return bot_data['uuid']
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
|
||||
'description',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'revision',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
)
|
||||
@@ -53,38 +54,11 @@ class SkillService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _box_service(self):
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'available', False):
|
||||
return box_service
|
||||
return None
|
||||
|
||||
def _require_box(self, action: str):
|
||||
"""Return the Box service or raise if it is not available.
|
||||
|
||||
Box is the only source of truth for skills. Every read and write
|
||||
operation goes through it — there is no local-filesystem fallback.
|
||||
"""
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
return box_service
|
||||
ap_box = getattr(self.ap, 'box_service', None)
|
||||
if ap_box is None:
|
||||
reason = 'not initialised'
|
||||
elif not getattr(ap_box, 'enabled', True):
|
||||
reason = 'disabled in config (box.enabled = false)'
|
||||
else:
|
||||
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
||||
reason = f'unavailable: {connector_error}'
|
||||
raise ValueError(
|
||||
f'{action} requires the Box runtime, which is {reason}. '
|
||||
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
||||
f'runtime is reachable before retrying.'
|
||||
)
|
||||
|
||||
def _require_box_for_write(self, action: str) -> None:
|
||||
"""Backwards-compatible alias preserved for clarity at call sites."""
|
||||
self._require_box(action)
|
||||
def _repository(self):
|
||||
repository = getattr(self.ap, 'skill_repository', None)
|
||||
if repository is None:
|
||||
raise ValueError('Skill repository is not initialised')
|
||||
return repository
|
||||
|
||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -113,20 +87,11 @@ class SkillService:
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
# When Box is unavailable, surface an empty list rather than raising —
|
||||
# the skills page should render cleanly, and the UI separately renders
|
||||
# a "Box disabled / unavailable" banner via useBoxStatus.
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return []
|
||||
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
|
||||
return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
|
||||
|
||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return None
|
||||
skill = await box_service.get_skill(execution_context, skill_name)
|
||||
skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
|
||||
return self._serialize_skill(skill) if skill else None
|
||||
|
||||
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
||||
@@ -134,22 +99,25 @@ class SkillService:
|
||||
|
||||
async def create_skill(self, context: TenantContext, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Creating a skill')
|
||||
created = await box_service.create_skill(execution_context, data)
|
||||
created = await self._repository().create_skill(execution_context, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def import_skill_directory(self, context: TenantContext, path: str, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
created = await self._repository().import_skill_directory(execution_context, path, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing a skill')
|
||||
updated = await box_service.update_skill(execution_context, skill_name, data)
|
||||
updated = await self._repository().update_skill(execution_context, skill_name, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(updated)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Deleting a skill')
|
||||
await box_service.delete_skill(execution_context, skill_name)
|
||||
await self._repository().delete_skill(execution_context, skill_name)
|
||||
await self._reload_skills(execution_context)
|
||||
return True
|
||||
|
||||
@@ -162,24 +130,27 @@ class SkillService:
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Browsing skill files')
|
||||
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
|
||||
return await self._repository().list_skill_files(
|
||||
execution_context,
|
||||
skill_name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Reading a skill file')
|
||||
return await box_service.read_skill_file(execution_context, skill_name, path)
|
||||
return await self._repository().read_skill_file(execution_context, skill_name, path)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing skill files')
|
||||
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
|
||||
result = await self._repository().write_skill_file(execution_context, skill_name, path, content)
|
||||
await self._reload_skills(execution_context)
|
||||
return result
|
||||
|
||||
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -198,7 +169,7 @@ class SkillService:
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -211,7 +182,7 @@ class SkillService:
|
||||
|
||||
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -228,7 +199,7 @@ class SkillService:
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
return await box_service.preview_skill_zip(
|
||||
return await repository.preview_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||
@@ -245,8 +216,7 @@ class SkillService:
|
||||
source_path: str = '',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from upload')
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await self._repository().install_skill_zip(
|
||||
execution_context,
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -264,8 +234,7 @@ class SkillService:
|
||||
filename: str,
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill upload')
|
||||
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
|
||||
return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
|
||||
|
||||
async def _install_github_skill_md(
|
||||
self,
|
||||
@@ -276,14 +245,14 @@ class SkillService:
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -302,13 +271,13 @@ class SkillService:
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
|
||||
async def reload_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
@@ -317,8 +286,7 @@ class SkillService:
|
||||
|
||||
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Scanning a skill directory')
|
||||
return await box_service.scan_skill_directory(execution_context, path)
|
||||
return await self._repository().scan_skill_directory(execution_context, path)
|
||||
|
||||
async def _reload_skills(self, context: TenantContext) -> None:
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
|
||||
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
- An explicit ``runtime.endpoint`` was configured
|
||||
|
||||
When this is True the Box runtime lives in a separate process with its
|
||||
own filesystem view (container, pod sidecar, or remote host), so paths
|
||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
||||
LangBot side. When False, Box runs as a stdio child process that shares
|
||||
LangBot's filesystem.
|
||||
own filesystem view (container, pod sidecar, or remote host), so only
|
||||
explicitly shared paths are usable on both sides. When False, Box runs
|
||||
as a stdio child process that shares LangBot's filesystem.
|
||||
"""
|
||||
return bool(
|
||||
self.configured_runtime_endpoint
|
||||
|
||||
+45
-211
@@ -28,8 +28,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
BoxHostMountMode,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxMountSpec,
|
||||
BoxProfile,
|
||||
BoxSpec,
|
||||
)
|
||||
@@ -169,7 +171,7 @@ class BoxService:
|
||||
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
||||
self.ap.logger.info(
|
||||
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
||||
'skill add/edit, stdio MCP) will be unavailable.'
|
||||
'stdio MCP, executable package scripts) will be unavailable.'
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -275,10 +277,6 @@ class BoxService:
|
||||
await self._purge_attachment_dirs()
|
||||
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) and not self._cloud_managed:
|
||||
await reload_skills()
|
||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -358,19 +356,17 @@ class BoxService:
|
||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||
|
||||
This is True only when Box runs as a local stdio child process of
|
||||
LangBot (same container/host). In that case paths the Box runtime
|
||||
reports — notably skill ``package_root`` — resolve identically on the
|
||||
LangBot side, so LangBot may validate them against its own filesystem.
|
||||
LangBot (same container/host). In that case host paths resolve
|
||||
identically on both sides and Core may perform local filesystem work.
|
||||
|
||||
It is False for every separated deployment (Docker Compose, k8s
|
||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||
where the Box runtime owns its own filesystem and LangBot must trust
|
||||
the paths it reports rather than checking them locally.
|
||||
where only explicitly shared and identically mounted roots can cross
|
||||
the process boundary.
|
||||
|
||||
When Box is wired up with an injected client (tests, custom embeds)
|
||||
there is no connector to introspect; we conservatively report False so
|
||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
there is no connector to introspect; we conservatively report False.
|
||||
An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
embedder that knows the real topology).
|
||||
"""
|
||||
if self._shares_filesystem_with_box_override is not None:
|
||||
@@ -483,11 +479,18 @@ class BoxService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
*,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
||||
|
||||
payload = dict(spec_payload)
|
||||
trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or [])
|
||||
if not self._cloud_managed:
|
||||
if trusted_mounts:
|
||||
if payload.get('extra_mounts'):
|
||||
raise BoxValidationError('extra_mounts and trusted_read_only_mounts cannot both be supplied')
|
||||
payload['extra_mounts'] = trusted_mounts
|
||||
return payload
|
||||
policy = self._admission_policy
|
||||
if policy is None:
|
||||
@@ -537,7 +540,7 @@ class BoxService:
|
||||
'network': 'off',
|
||||
'host_path': canonical_host_path,
|
||||
'mount_path': '/workspace',
|
||||
'extra_mounts': [],
|
||||
'extra_mounts': trusted_mounts,
|
||||
'persistent': True,
|
||||
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
||||
'cpus': policy.cpus,
|
||||
@@ -549,6 +552,22 @@ class BoxService:
|
||||
)
|
||||
return payload
|
||||
|
||||
def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]:
|
||||
"""Validate Core-composed artifacts before crossing into Box."""
|
||||
|
||||
normalized: list[dict] = []
|
||||
for raw_mount in mounts:
|
||||
mount = BoxMountSpec.model_validate(raw_mount)
|
||||
if mount.mode != BoxHostMountMode.READ_ONLY:
|
||||
raise BoxAdmissionError('Core-composed additional mounts must be read-only')
|
||||
host_path = os.path.realpath(mount.host_path)
|
||||
if not os.path.isdir(host_path):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is unavailable')
|
||||
if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is outside allowed_mount_roots')
|
||||
normalized.append(mount.model_copy(update={'host_path': host_path}).model_dump(mode='json'))
|
||||
return normalized
|
||||
|
||||
def _reject_cloud_managed_process(self) -> None:
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
||||
@@ -565,13 +584,18 @@ class BoxService:
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
if not self._available:
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
spec_payload = self._managed_policy_payload(
|
||||
execution_context,
|
||||
spec_payload,
|
||||
trusted_read_only_mounts=trusted_read_only_mounts,
|
||||
)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
@@ -658,70 +682,12 @@ class BoxService:
|
||||
variables.setdefault('global', 'global')
|
||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||
|
||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
||||
|
||||
This ensures that when a container is first created it already has
|
||||
all skill packages mounted, regardless of which skill is currently
|
||||
activated.
|
||||
|
||||
Path validation is filesystem-topology dependent. When LangBot and the
|
||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
||||
``package_root`` is missing or no longer a directory is skipped with a
|
||||
warning instead of being passed through to the backend. Without that
|
||||
guard the three backends behave inconsistently on a stale mount: nsjail
|
||||
refuses to start the sandbox (failing every exec in the session),
|
||||
Docker silently auto-creates a root-owned empty directory on the host,
|
||||
and E2B silently skips the upload — none of which surfaces an
|
||||
actionable error.
|
||||
|
||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
if self._cloud_managed:
|
||||
return []
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
validate_locally = self.shares_filesystem_with_box
|
||||
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
mounts: list[dict] = []
|
||||
for skill_name, skill_data in visible_skills.items():
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
continue
|
||||
if validate_locally and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
||||
f'The skill cache may be stale — consider reloading skills.'
|
||||
)
|
||||
continue
|
||||
mounts.append(
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||
'mode': 'rw',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skill_name: str | None = None,
|
||||
read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Execute an agent-facing ``exec`` tool call.
|
||||
|
||||
@@ -729,8 +695,6 @@ class BoxService:
|
||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||
"""
|
||||
spec_payload: dict = {'cmd': parameters['command']}
|
||||
if skill_name is not None:
|
||||
spec_payload['skill_name'] = skill_name
|
||||
|
||||
# Pass through allowed agent-facing fields
|
||||
for key in ('workdir', 'timeout_sec', 'env'):
|
||||
@@ -740,11 +704,11 @@ class BoxService:
|
||||
# Inject context the agent must not control
|
||||
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
||||
|
||||
# Mount all pipeline-bound skills so they are available in the container
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
|
||||
return await self.execute_spec_payload(spec_payload, query)
|
||||
return await self.execute_spec_payload(
|
||||
spec_payload,
|
||||
query,
|
||||
trusted_read_only_mounts=read_only_mounts,
|
||||
)
|
||||
|
||||
async def execute_in_context(
|
||||
self,
|
||||
@@ -1275,8 +1239,6 @@ class BoxService:
|
||||
'timeout_sec': 120,
|
||||
'session_id': self.resolve_box_session_id(query),
|
||||
}
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
try:
|
||||
spec = self.build_spec(spec_payload)
|
||||
result = await self.client.execute(spec)
|
||||
@@ -1527,126 +1489,6 @@ class BoxService:
|
||||
self._runtime_connector.get_relay_headers(action_context),
|
||||
)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skills(action_context=self._action_context(execution_context))
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
|
||||
raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
|
||||
if self._cloud_managed:
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed:
|
||||
# The runtime already owns the package path for an existing skill.
|
||||
# A serialized read response may contain it, but it is never an
|
||||
# authority-bearing update field in shared Cloud mode.
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.update_skill(
|
||||
name,
|
||||
payload,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
|
||||
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
|
||||
|
||||
async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
return execution_context
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skill_files(
|
||||
name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.read_skill_file(
|
||||
name,
|
||||
path,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.write_skill_file(
|
||||
name,
|
||||
path,
|
||||
content,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def preview_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.preview_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def install_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_paths: list[str] | None = None,
|
||||
source_path: str = '',
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.install_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_paths,
|
||||
source_path,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||
@@ -1783,14 +1625,6 @@ class BoxService:
|
||||
default_workspace = os.path.join(self.host_root, default_workspace)
|
||||
return os.path.realpath(os.path.abspath(default_workspace))
|
||||
|
||||
def get_skills_root(self) -> str | None:
|
||||
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
|
||||
if not skills_root:
|
||||
skills_root = 'skills'
|
||||
if not os.path.isabs(skills_root) and self.host_root is not None:
|
||||
skills_root = os.path.join(self.host_root, skills_root)
|
||||
return os.path.realpath(os.path.abspath(skills_root))
|
||||
|
||||
def _load_enabled(self) -> bool:
|
||||
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
||||
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
"""Reusable workspace/session helpers built on top of Box.
|
||||
|
||||
This module is the middle layer between the raw Box runtime primitives and
|
||||
application-specific flows such as skills or MCP stdio.
|
||||
This module is the middle layer between raw Box runtime primitives and
|
||||
application-specific consumers.
|
||||
|
||||
It intentionally stays generic:
|
||||
- path and virtualenv rewriting are workspace concerns
|
||||
- Python project detection/bootstrap are workspace concerns
|
||||
- session exec / managed-process helpers are workspace concerns
|
||||
|
||||
Higher layers add their own semantics on top, for example:
|
||||
- skills choose a stable per-skill session id and use repeated exec
|
||||
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
||||
Higher layers add their own semantics on top; BoxWorkspaceSession retains only
|
||||
workspace, execution, and managed-process concepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from typing import Any
|
||||
|
||||
PYTHON_MANIFEST_FILES = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
from ..utils.python_workspace import list_python_manifest_files
|
||||
|
||||
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
||||
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
||||
|
||||
|
||||
def normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
||||
"""Translate a host path into the path visible inside the sandbox mount."""
|
||||
if not host_path or not path:
|
||||
@@ -98,13 +83,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
|
||||
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
|
||||
|
||||
|
||||
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return []
|
||||
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
||||
|
||||
|
||||
def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
"""Return the generic Python workspace shape, without app-specific policy."""
|
||||
manifest_files = set(list_python_manifest_files(host_path))
|
||||
@@ -117,163 +95,6 @@ def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def should_prepare_python_env(host_path: str | None) -> bool:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return bool(list_python_manifest_files(normalized_root))
|
||||
|
||||
|
||||
def wrap_python_command_with_env(
|
||||
command: str,
|
||||
*,
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
|
||||
|
||||
``mount_path`` is always the source tree used for manifest hashing and
|
||||
installation. ``state_path`` may point at a separate writable directory
|
||||
for read-only source mounts; when omitted, legacy mutable-workspace behavior
|
||||
stores the environment beside the source.
|
||||
"""
|
||||
writable_state_path = state_path or mount_path
|
||||
bootstrap = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="{writable_state_path}/.venv"
|
||||
_LB_META_DIR="{writable_state_path}/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="{writable_state_path}/.tmp"
|
||||
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "{mount_path}"
|
||||
max_manifest_bytes = 10 * 1024 * 1024
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if os.path.getsize(path) > max_manifest_bytes:
|
||||
raise RuntimeError(
|
||||
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
|
||||
)
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\\0")
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
}},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
|
||||
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
python -m pip install -r "{mount_path}/requirements.txt"
|
||||
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
||||
python -m pip install "{mount_path}"
|
||||
fi
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
{command}
|
||||
"""
|
||||
).strip()
|
||||
return bootstrap + '\n'
|
||||
|
||||
|
||||
class BoxWorkspaceSession:
|
||||
"""High-level handle for one reusable workspace-backed Box session.
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from . import entities as core_entities
|
||||
from ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..rag.service import RAGRuntimeService
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..skill import repository as skill_repository
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
@@ -84,6 +85,7 @@ class Application:
|
||||
# TODO move to pipeline
|
||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||
box_service: box_service_module.BoxService = None
|
||||
skill_repository: skill_repository.SkillRepository = None
|
||||
|
||||
# ======= Config manager =======
|
||||
|
||||
@@ -635,9 +637,9 @@ class Application:
|
||||
frontend_path = paths.get_frontend_path()
|
||||
|
||||
if not os.path.exists(frontend_path):
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://docs.langbot.app/zh')
|
||||
self.logger.warning('WebUI 文件缺失,请根据文档部署:https://langbot.app/docs/zh')
|
||||
self.logger.warning(
|
||||
'WebUI files are missing, please deploy according to the documentation: https://docs.langbot.app/en'
|
||||
'WebUI files are missing, please deploy according to the documentation: https://langbot.app/docs/en'
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...skill import repository as skill_repository
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...skill import manager as skill_mgr
|
||||
@@ -127,6 +128,7 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
ap.skill_repository = skill_repository.SkillRepository(ap)
|
||||
skill_service_inst = skill_service.SkillService(ap)
|
||||
ap.skill_service = skill_service_inst
|
||||
|
||||
|
||||
@@ -328,6 +328,8 @@ class PreProcessor(stage.PipelineStage):
|
||||
# relied on this injection; without it the LLM never discovers
|
||||
# the skills are there and just calls native tools instead.
|
||||
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
||||
available_tool_names = {tool.name for tool in query.use_funcs}
|
||||
query.variables['_skill_execution_available'] = 'exec' in available_tool_names
|
||||
skill_execution_context = get_query_execution_context(query)
|
||||
await self.ap.skill_mgr.ensure_loaded(skill_execution_context)
|
||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(
|
||||
@@ -349,7 +351,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
skill_execution_context,
|
||||
bound_skills=bound_skills,
|
||||
)
|
||||
if skill_addition:
|
||||
if skill_addition and 'activate' in available_tool_names:
|
||||
self._append_to_system_prompt(query.prompt.messages, skill_addition)
|
||||
self.ap.logger.debug(
|
||||
f'Skill index injected into system prompt: '
|
||||
@@ -357,7 +359,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
f'bound_skills={bound_skills or "all"} '
|
||||
f'loaded_skills={len(self.ap.skill_mgr.get_skills(skill_execution_context))}'
|
||||
)
|
||||
else:
|
||||
elif 'activate' in available_tool_names:
|
||||
self.ap.logger.debug(
|
||||
f'No skills available for prompt injection: '
|
||||
f'pipeline={query.pipeline_uuid} '
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||
en: https://link.langbot.app/en/platforms/dingtalk
|
||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/dingtalk
|
||||
en: https://langbot.app/docs/en/usage/platforms/dingtalk
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/dingtalk
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/discord
|
||||
en: https://link.langbot.app/en/platforms/discord
|
||||
ja: https://link.langbot.app/ja/platforms/discord
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/discord
|
||||
en: https://langbot.app/docs/en/usage/platforms/discord
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/discord
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
|
||||
@@ -18,9 +18,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://docs.langbot.app/zh/platforms/http-bot
|
||||
en: https://docs.langbot.app/en/platforms/http-bot
|
||||
ja: https://docs.langbot.app/ja/platforms/http-bot
|
||||
zh: https://langbot.app/docs/zh/platforms/http-bot
|
||||
en: https://langbot.app/docs/en/platforms/http-bot
|
||||
ja: https://langbot.app/docs/ja/platforms/http-bot
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/kook
|
||||
en: https://link.langbot.app/en/platforms/kook
|
||||
ja: https://link.langbot.app/ja/platforms/kook
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/kook
|
||||
en: https://langbot.app/docs/en/usage/platforms/kook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/kook
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -19,9 +19,9 @@ spec:
|
||||
- china
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/lark
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/lark
|
||||
en: https://langbot.app/docs/en/usage/platforms/lark
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
|
||||
@@ -22,9 +22,9 @@ spec:
|
||||
categories:
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/line
|
||||
en: https://link.langbot.app/en/platforms/line
|
||||
ja: https://link.langbot.app/ja/platforms/line
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/line
|
||||
en: https://langbot.app/docs/en/usage/platforms/line
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/line
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||
en: https://link.langbot.app/en/platforms/officialaccount
|
||||
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wxoa
|
||||
en: https://langbot.app/docs/en/usage/platforms/wxoa
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wxoa
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/weixin
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
|
||||
@@ -205,7 +205,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
token=config.get('token', ''),
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/official_webhook
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/official_webhook
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/official_webhook
|
||||
config:
|
||||
- name: __system.outbound_ips
|
||||
label:
|
||||
|
||||
@@ -21,9 +21,9 @@ spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/satori
|
||||
en: https://link.langbot.app/en/platforms/satori
|
||||
ja: https://link.langbot.app/ja/platforms/satori
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/readme
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: platform
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/slack
|
||||
en: https://link.langbot.app/en/platforms/slack
|
||||
ja: https://link.langbot.app/ja/platforms/slack
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/slack
|
||||
en: https://langbot.app/docs/en/usage/platforms/slack
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/slack
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -24,9 +24,9 @@ spec:
|
||||
- popular
|
||||
- global
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/telegram
|
||||
en: https://link.langbot.app/en/platforms/telegram
|
||||
ja: https://link.langbot.app/ja/platforms/telegram
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/telegram
|
||||
en: https://langbot.app/docs/en/usage/platforms/telegram
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/telegram
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wechatpad
|
||||
en: https://link.langbot.app/en/platforms/wechatpad
|
||||
ja: https://link.langbot.app/ja/platforms/wechatpad
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wechat/wechatpad
|
||||
en: https://langbot.app/docs/en/usage/platforms/readme
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/readme
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
|
||||
@@ -274,11 +274,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
await self.bot.send_image(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
await self.bot.send_voice(user_id, agent_id, content['media_id'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
await self.bot.send_file(user_id, agent_id, content['media_id'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
|
||||
@@ -16,9 +16,9 @@ spec:
|
||||
- popular
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecom
|
||||
en: https://link.langbot.app/en/platforms/wecom
|
||||
ja: https://link.langbot.app/ja/platforms/wecom
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecom
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecom
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecom
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||
en: https://link.langbot.app/en/platforms/wecombot
|
||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecombot
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecombot
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecombot
|
||||
config:
|
||||
- name: one-click-create
|
||||
label:
|
||||
|
||||
@@ -15,9 +15,9 @@ spec:
|
||||
categories:
|
||||
- china
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||
en: https://link.langbot.app/en/platforms/wecomcs
|
||||
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/wecom/wecomcs
|
||||
en: https://langbot.app/docs/en/usage/platforms/wecom/wecomcs
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/wecom/wecomcs
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
|
||||
@@ -39,6 +39,9 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
# 获取输出键名,默认为response
|
||||
self.output_key = self.pipeline_config['ai']['n8n-service-api'].get('output-key', 'response')
|
||||
self.response_handling = self.pipeline_config['ai']['n8n-service-api'].get('response-handling', 'reply')
|
||||
if self.response_handling not in {'reply', 'ignore'}:
|
||||
raise ValueError(f'Invalid n8n response-handling: {self.response_handling}')
|
||||
|
||||
# 获取认证类型,默认为none
|
||||
self.auth_type = self.pipeline_config['ai']['n8n-service-api'].get('auth-type', 'none')
|
||||
@@ -262,7 +265,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
async with session.post(
|
||||
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
if self.response_handling == 'ignore':
|
||||
status_ok = 200 <= response.status < 300
|
||||
else:
|
||||
status_ok = response.status == 200
|
||||
if not status_ok:
|
||||
error_text = (
|
||||
await httpclient.read_limited(
|
||||
response,
|
||||
@@ -272,6 +279,11 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
|
||||
|
||||
if self.response_handling == 'ignore':
|
||||
response.release()
|
||||
self.ap.logger.debug('n8n async webhook accepted; response body ignored')
|
||||
return
|
||||
|
||||
async for chunk in self._process_response(response):
|
||||
if is_stream:
|
||||
yield chunk
|
||||
|
||||
@@ -18,12 +18,11 @@ from ....box.workspace import (
|
||||
BoxWorkspaceSession,
|
||||
classify_python_workspace,
|
||||
infer_workspace_host_path,
|
||||
normalize_host_path,
|
||||
rewrite_mounted_path,
|
||||
rewrite_venv_command,
|
||||
unwrap_venv_path,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
from ....utils.python_workspace import normalize_host_path, wrap_python_command_with_env
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .mcp import RuntimeMCPSession
|
||||
|
||||
@@ -32,7 +32,7 @@ EDIT_TOOL_NAME = 'edit'
|
||||
GLOB_TOOL_NAME = 'glob'
|
||||
GREP_TOOL_NAME = 'grep'
|
||||
|
||||
_ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}
|
||||
SANDBOX_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}
|
||||
|
||||
# Skip these dirs during grep walk to avoid noise
|
||||
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}
|
||||
@@ -260,7 +260,11 @@ 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 SANDBOX_TOOL_NAMES and await self._is_sandbox_available()
|
||||
|
||||
@staticmethod
|
||||
def recognizes_tool(name: str) -> bool:
|
||||
return name in SANDBOX_TOOL_NAMES
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||
require_sandbox = getattr(
|
||||
@@ -325,20 +329,11 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
if not package_root:
|
||||
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
|
||||
|
||||
# Pass only the logical name across the authenticated Core→Runtime
|
||||
# boundary. In Cloud mode the shared Box Runtime resolves the
|
||||
# Workspace-scoped package root and constructs the read-only mount;
|
||||
# Core host paths are never accepted as mount authority.
|
||||
# Wrap command with Python venv bootstrap if the skill has a Python project.
|
||||
# The venv is created inside the skill's mount path.
|
||||
skill_mount = f'/workspace/.skills/{selected_skill_name}'
|
||||
python_project = selected_skill.get('python_project') is True
|
||||
if 'python_project' not in selected_skill and bool(
|
||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
||||
):
|
||||
# Backward compatibility for a same-process OSS Runtime that
|
||||
# predates trusted Box metadata. Never probe a path reported by
|
||||
# an external Runtime from the Core filesystem.
|
||||
if 'python_project' not in selected_skill:
|
||||
python_project = skill_loader.should_prepare_skill_python_env(package_root)
|
||||
if python_project:
|
||||
parameters = dict(parameters)
|
||||
@@ -354,12 +349,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
result = await self.ap.box_service.execute_tool(
|
||||
parameters,
|
||||
query,
|
||||
skill_name=selected_skill_name,
|
||||
read_only_mounts=skill_loader.build_execution_mounts(self.ap, query),
|
||||
)
|
||||
result = self._normalize_exec_result(result)
|
||||
|
||||
if selected_skill is not None:
|
||||
self._refresh_skill_from_disk(query, selected_skill)
|
||||
return result
|
||||
|
||||
def _resolve_host_location(
|
||||
@@ -381,10 +373,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
box_service = self.ap.box_service
|
||||
if selected_skill is not None:
|
||||
if not self._can_interpret_skill_host_paths():
|
||||
raise ValueError(
|
||||
'Skill package paths are owned by the Box Runtime; '
|
||||
'this operation requires a Runtime skill-file API.'
|
||||
)
|
||||
raise ValueError('Secure Core host file operations are unavailable on this platform.')
|
||||
host_root = selected_skill.get('package_root')
|
||||
workspace_anchor = None
|
||||
else:
|
||||
@@ -422,11 +411,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return selected_skill, relative
|
||||
|
||||
def _can_interpret_skill_host_paths(self) -> bool:
|
||||
"""Require an explicitly proven shared Core/Runtime filesystem view."""
|
||||
"""Return whether Core can use its no-follow host file primitives."""
|
||||
|
||||
return _SECURE_HOST_FILE_OPS_AVAILABLE and bool(
|
||||
getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
|
||||
)
|
||||
return _SECURE_HOST_FILE_OPS_AVAILABLE
|
||||
|
||||
def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
|
||||
if selected_skill is not None:
|
||||
@@ -1123,20 +1110,11 @@ else:
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
|
||||
skill_repository = getattr(self.ap, 'skill_repository', None)
|
||||
if skill_request is not None and skill_repository is not None:
|
||||
selected_skill, relative = skill_request
|
||||
if self._can_interpret_skill_host_paths():
|
||||
host_location = self._resolve_skill_host_location(selected_skill, relative)
|
||||
else:
|
||||
host_location = None
|
||||
if host_location is not None:
|
||||
try:
|
||||
return await asyncio.to_thread(self._read_host_location, host_location, parameters)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(
|
||||
result = await skill_repository.read_skill_file(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
@@ -1144,7 +1122,7 @@ else:
|
||||
return self._build_read_result_from_text(str(result.get('content', '')), parameters)
|
||||
except Exception:
|
||||
try:
|
||||
result = await self.ap.box_service.list_skill_files(
|
||||
result = await skill_repository.list_skill_files(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
@@ -1178,12 +1156,13 @@ else:
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'):
|
||||
skill_repository = getattr(self.ap, 'skill_repository', None)
|
||||
if skill_request is not None and skill_repository is not None:
|
||||
if encoding != 'text':
|
||||
return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
|
||||
selected_skill, relative = skill_request
|
||||
execution_context = self._execution_context(query)
|
||||
await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
|
||||
await skill_repository.write_skill_file(execution_context, selected_skill['name'], relative, content)
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
@@ -1216,14 +1195,10 @@ else:
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if (
|
||||
skill_request is not None
|
||||
and hasattr(self.ap.box_service, 'read_skill_file')
|
||||
and hasattr(self.ap.box_service, 'write_skill_file')
|
||||
):
|
||||
if skill_request is not None and getattr(self.ap, 'skill_repository', None) is not None:
|
||||
selected_skill, relative = skill_request
|
||||
try:
|
||||
result = await self.ap.box_service.read_skill_file(
|
||||
result = await self.ap.skill_repository.read_skill_file(
|
||||
self._execution_context(query),
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
@@ -1238,7 +1213,7 @@ else:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
execution_context = self._execution_context(query)
|
||||
await self.ap.box_service.write_skill_file(
|
||||
await self.ap.skill_repository.write_skill_file(
|
||||
execution_context,
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import typing
|
||||
|
||||
from ....box import workspace as box_workspace
|
||||
from ....api.http.context import ExecutionContext
|
||||
from ....utils.python_workspace import (
|
||||
should_prepare_python_env,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core import app
|
||||
@@ -57,6 +61,30 @@ def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_na
|
||||
return get_visible_skills(ap, query).get(skill_name)
|
||||
|
||||
|
||||
def build_execution_mounts(ap: app.Application, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Translate visible Core-owned packages into generic read-only mounts."""
|
||||
|
||||
mounts: list[dict] = []
|
||||
for skill_name, skill_data in get_visible_skills(ap, query).items():
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
continue
|
||||
if not os.path.isdir(package_root):
|
||||
ap.logger.warning(
|
||||
f'Skill "{skill_name}" package_root missing on the Core filesystem '
|
||||
f'({package_root}); skipping its execution mount. Reload the skill catalog.'
|
||||
)
|
||||
continue
|
||||
mounts.append(
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': get_virtual_skill_mount_path(skill_name),
|
||||
'mode': 'ro',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
|
||||
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
|
||||
if query.variables is None:
|
||||
return {}
|
||||
@@ -198,7 +226,7 @@ def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str
|
||||
|
||||
|
||||
def should_prepare_skill_python_env(package_root: str | None) -> bool:
|
||||
return box_workspace.should_prepare_python_env(package_root)
|
||||
return should_prepare_python_env(package_root)
|
||||
|
||||
|
||||
def wrap_skill_command_with_python_env(
|
||||
@@ -207,7 +235,7 @@ def wrap_skill_command_with_python_env(
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
return box_workspace.wrap_python_command_with_env(
|
||||
return wrap_python_command_with_env(
|
||||
command,
|
||||
mount_path=mount_path,
|
||||
state_path=state_path,
|
||||
|
||||
@@ -6,7 +6,6 @@ import typing
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
from .availability import is_box_backend_available
|
||||
from ....api.http.context import ExecutionContext
|
||||
|
||||
# Align with Claude Code's Skill tool design:
|
||||
@@ -15,12 +14,23 @@ from ....api.http.context import ExecutionContext
|
||||
# - This protects KV Cache and follows industry standard
|
||||
|
||||
ACTIVATE_SKILL_TOOL_NAME = 'activate'
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources'
|
||||
READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource'
|
||||
REGISTER_SKILL_TOOL_NAME = 'register_skill'
|
||||
|
||||
SKILL_TOOL_NAMES = {
|
||||
READ_ONLY_SKILL_TOOL_NAMES = {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME,
|
||||
READ_SKILL_RESOURCE_TOOL_NAME,
|
||||
}
|
||||
SANDBOX_SKILL_TOOL_NAMES = {
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
}
|
||||
SKILL_TOOL_NAMES = READ_ONLY_SKILL_TOOL_NAMES | SANDBOX_SKILL_TOOL_NAMES
|
||||
_SKILL_EXECUTION_AVAILABLE_KEY = '_skill_execution_available'
|
||||
_SKILL_RESOURCE_BYTES_READ_KEY = '_skill_resource_bytes_read'
|
||||
_MAX_SKILL_RESOURCE_FILE_BYTES = 256 * 1024
|
||||
_MAX_SKILL_RESOURCE_RUN_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class SkillToolLoader(loader.ToolLoader):
|
||||
@@ -28,62 +38,73 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
def __init__(self, ap):
|
||||
super().__init__(ap)
|
||||
self._tools: list[resource_tool.LLMTool] = []
|
||||
self._sandbox_available: bool = False
|
||||
self._read_only_tools: list[resource_tool.LLMTool] = []
|
||||
self._sandbox_tools: list[resource_tool.LLMTool] = []
|
||||
|
||||
async def initialize(self):
|
||||
# Check if sandbox backend is available (same check as native tools)
|
||||
self._sandbox_available = await self._check_sandbox_available()
|
||||
if self._sandbox_available:
|
||||
self._tools = [
|
||||
if self._is_available():
|
||||
self._read_only_tools = [
|
||||
self._build_activate_skill_tool(),
|
||||
self._build_register_skill_tool(),
|
||||
self._build_list_skill_resources_tool(),
|
||||
self._build_read_skill_resource_tool(),
|
||||
]
|
||||
self._sandbox_tools = [self._build_register_skill_tool()]
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
'Skill tools (activate/register_skill) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host.'
|
||||
)
|
||||
self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.')
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
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():
|
||||
async def get_tools(
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
*,
|
||||
sandbox_available: bool | None = None,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
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)
|
||||
if not self._read_only_tools:
|
||||
await self.initialize()
|
||||
tools = list(self._read_only_tools)
|
||||
if sandbox_available:
|
||||
tools.extend(self._sandbox_tools)
|
||||
return tools
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return await self._is_available() and name in SKILL_TOOL_NAMES
|
||||
async def get_tool(self, name: str, *, sandbox_available: bool | None = None):
|
||||
for tool in await self.get_tools(sandbox_available=sandbox_available):
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
async 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():
|
||||
async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool:
|
||||
if not self._is_available() or name not in SKILL_TOOL_NAMES:
|
||||
return False
|
||||
self._sandbox_available = await self._check_sandbox_available()
|
||||
return self._sandbox_available
|
||||
return name in READ_ONLY_SKILL_TOOL_NAMES or bool(sandbox_available)
|
||||
|
||||
@staticmethod
|
||||
def is_sandbox_tool(name: str) -> bool:
|
||||
return name in SANDBOX_SKILL_TOOL_NAMES
|
||||
|
||||
@staticmethod
|
||||
def recognizes_tool(name: str) -> bool:
|
||||
return name in SKILL_TOOL_NAMES
|
||||
|
||||
def _is_available(self) -> bool:
|
||||
return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if callable(require_sandbox):
|
||||
await require_sandbox(self._execution_context(query))
|
||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_activate_skill(parameters, query)
|
||||
if name == LIST_SKILL_RESOURCES_TOOL_NAME:
|
||||
return await self._invoke_list_skill_resources(parameters, query)
|
||||
if name == READ_SKILL_RESOURCE_TOOL_NAME:
|
||||
return await self._invoke_read_skill_resource(parameters, query)
|
||||
if name == REGISTER_SKILL_TOOL_NAME:
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if not callable(require_sandbox):
|
||||
return self._sandbox_unavailable_result(name)
|
||||
await require_sandbox(self._execution_context(query))
|
||||
return await self._invoke_register_skill(parameters, query)
|
||||
raise ValueError(f'Unknown skill tool: {name}')
|
||||
|
||||
@@ -108,6 +129,27 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
def _has_skill_manager(self) -> bool:
|
||||
return getattr(self.ap, 'skill_mgr', None) is not None
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_unavailable_result(name: str) -> dict:
|
||||
return {
|
||||
'ok': False,
|
||||
'code': 'sandbox_unavailable',
|
||||
'tool': name,
|
||||
'message': 'This operation requires Box execution, but Box is not configured or available.',
|
||||
}
|
||||
|
||||
async def _execution_available(self, query) -> bool:
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict) and _SKILL_EXECUTION_AVAILABLE_KEY in variables:
|
||||
return bool(variables[_SKILL_EXECUTION_AVAILABLE_KEY])
|
||||
checker = getattr(getattr(self.ap, 'box_service', None), 'is_workspace_sandbox_available', None)
|
||||
if not callable(checker):
|
||||
return False
|
||||
try:
|
||||
return bool(await checker(self._execution_context(query)))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
|
||||
"""Activate a skill and return SKILL.md content via Tool Result."""
|
||||
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
||||
@@ -116,42 +158,111 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_data = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||
if skill_data is None:
|
||||
visible_skill = skill_loader.get_visible_skill(self.ap, query, skill_name)
|
||||
if visible_skill is None:
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
|
||||
raise ValueError(f'Skill "{skill_name}" not found. Available skills: {available_names}')
|
||||
|
||||
# Register activated skill for sandbox mount path resolution
|
||||
skill_data = await self.ap.skill_repository.get_skill(
|
||||
self._execution_context(query),
|
||||
skill_name,
|
||||
snapshot=True,
|
||||
)
|
||||
if skill_data is None:
|
||||
raise ValueError(f'Skill "{skill_name}" is no longer available; reload the skill catalog.')
|
||||
|
||||
skill_loader.register_activated_skill(query, skill_data)
|
||||
|
||||
# Return SKILL.md content as Tool Result (injects into context)
|
||||
instructions = skill_data.get('instructions', '')
|
||||
package_root = skill_data.get('package_root', '')
|
||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name)
|
||||
revision = str(skill_data.get('revision', '') or '')
|
||||
execution_available = await self._execution_available(query)
|
||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) if execution_available else None
|
||||
|
||||
# Build Tool Result content
|
||||
result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
|
||||
result_content += '<skill-activation>\n'
|
||||
result_content += f'<skill-name>{skill_name}</skill-name>\n'
|
||||
result_content += f'<mount-path>{mount_path}</mount-path>\n'
|
||||
result_content += f'<package-root>{package_root}</package-root>\n'
|
||||
result_content += f'<revision>{revision}</revision>\n'
|
||||
result_content += '<resources-readable>true</resources-readable>\n'
|
||||
result_content += f'<execution-available>{str(execution_available).lower()}</execution-available>\n'
|
||||
result_content += f'\n## Instructions\n{instructions}\n'
|
||||
result_content += '\n## Runtime Context\n'
|
||||
result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n'
|
||||
result_content += f'- Use `read` to inspect files under {mount_path}\n'
|
||||
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n'
|
||||
result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n'
|
||||
result_content += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n'
|
||||
if execution_available:
|
||||
result_content += (
|
||||
f'- Box execution is available; executable package files will be mounted at {mount_path}.\n'
|
||||
)
|
||||
else:
|
||||
result_content += (
|
||||
'- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n'
|
||||
)
|
||||
result_content += '</skill-activation>\n'
|
||||
|
||||
return {
|
||||
'activated': True,
|
||||
'skill_name': skill_name,
|
||||
'mount_path': mount_path,
|
||||
'revision': revision,
|
||||
'capabilities': {
|
||||
'instructions_readable': True,
|
||||
'resources_readable': True,
|
||||
'execution_available': execution_available,
|
||||
},
|
||||
'activated_skill_names': skill_loader.get_activated_skill_names(query),
|
||||
'content': result_content,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _activated_skill(parameters: dict, query) -> dict:
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
||||
if not skill_name:
|
||||
raise ValueError('skill_name is required')
|
||||
skill_data = skill_loader.get_activated_skill(query, skill_name)
|
||||
if skill_data is None:
|
||||
raise ValueError(f'Skill "{skill_name}" must be activated before its resources can be read.')
|
||||
requested_revision = str(parameters.get('revision', '') or '').strip()
|
||||
activated_revision = str(skill_data.get('revision', '') or '').strip()
|
||||
if requested_revision and requested_revision != activated_revision:
|
||||
raise ValueError('revision must match the activated skill revision')
|
||||
return skill_data
|
||||
|
||||
async def _invoke_list_skill_resources(self, parameters: dict, query) -> dict:
|
||||
skill_data = self._activated_skill(parameters, query)
|
||||
return await self.ap.skill_repository.list_skill_resources(
|
||||
self._execution_context(query),
|
||||
skill_data['name'],
|
||||
str(parameters.get('path', '.') or '.'),
|
||||
expected_revision=skill_data.get('revision'),
|
||||
)
|
||||
|
||||
async def _invoke_read_skill_resource(self, parameters: dict, query) -> dict:
|
||||
skill_data = self._activated_skill(parameters, query)
|
||||
path = str(parameters.get('path', '') or '').strip()
|
||||
if not path:
|
||||
raise ValueError('path is required')
|
||||
result = await self.ap.skill_repository.read_skill_resource(
|
||||
self._execution_context(query),
|
||||
skill_data['name'],
|
||||
path,
|
||||
expected_revision=skill_data.get('revision'),
|
||||
)
|
||||
content = str(result.get('content', ''))
|
||||
size = len(content.encode('utf-8'))
|
||||
if size > _MAX_SKILL_RESOURCE_FILE_BYTES:
|
||||
raise ValueError('Skill resource exceeds the per-file read limit')
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
total = int(variables.get(_SKILL_RESOURCE_BYTES_READ_KEY, 0) or 0) + size
|
||||
if total > _MAX_SKILL_RESOURCE_RUN_BYTES:
|
||||
raise ValueError('Skill resource reads exceed the per-run limit')
|
||||
variables[_SKILL_RESOURCE_BYTES_READ_KEY] = total
|
||||
result['size'] = size
|
||||
return result
|
||||
|
||||
async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
|
||||
"""Register a skill from sandbox directory to data/skills/."""
|
||||
sandbox_path = str(parameters.get('path', '') or '').strip()
|
||||
@@ -176,14 +287,14 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
raise ValueError('skill name is required')
|
||||
|
||||
# Create the skill
|
||||
created = await skill_service.create_skill(
|
||||
created = await skill_service.import_skill_directory(
|
||||
execution_context,
|
||||
host_path,
|
||||
{
|
||||
'name': skill_name,
|
||||
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
||||
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
||||
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
||||
'package_root': host_path,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -249,6 +360,42 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_list_skill_resources_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=LIST_SKILL_RESOURCES_TOOL_NAME,
|
||||
human_desc='List activated skill resources',
|
||||
description='List read-only files in an activated skill package without starting a sandbox.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
|
||||
'path': {'type': 'string', 'description': 'Relative directory path. Defaults to the package root.'},
|
||||
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
|
||||
},
|
||||
'required': ['skill_name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_read_skill_resource_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=READ_SKILL_RESOURCE_TOOL_NAME,
|
||||
human_desc='Read an activated skill resource',
|
||||
description='Read a UTF-8 text resource from an activated skill package without starting a sandbox.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'skill_name': {'type': 'string', 'description': 'The activated skill name.'},
|
||||
'path': {'type': 'string', 'description': 'File path relative to the skill package root.'},
|
||||
'revision': {'type': 'string', 'description': 'Optional revision returned by activate.'},
|
||||
},
|
||||
'required': ['skill_name', 'path'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_register_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=REGISTER_SKILL_TOOL_NAME,
|
||||
|
||||
@@ -66,6 +66,15 @@ class ToolManager:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_unavailable_result(name: str) -> dict[str, typing.Any]:
|
||||
return {
|
||||
'ok': False,
|
||||
'code': 'sandbox_unavailable',
|
||||
'tool': name,
|
||||
'message': 'This operation requires Box execution, but Box is not configured or available.',
|
||||
}
|
||||
|
||||
async def initialize(self):
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
@@ -102,8 +111,8 @@ class ToolManager:
|
||||
sandbox_available = await self._workspace_sandbox_available(context)
|
||||
if sandbox_available:
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring and sandbox_available:
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
all_functions.extend(await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available))
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(
|
||||
await self.mcp_tool_loader.get_tools(
|
||||
@@ -142,8 +151,12 @@ class ToolManager:
|
||||
sandbox_available = await self._workspace_sandbox_available(context)
|
||||
if sandbox_available:
|
||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring and sandbox_available:
|
||||
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
append_tools(
|
||||
'skill',
|
||||
'LangBot',
|
||||
await self.skill_tool_loader.get_tools(sandbox_available=sandbox_available),
|
||||
)
|
||||
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
|
||||
|
||||
if self.mcp_tool_loader:
|
||||
@@ -168,10 +181,9 @@ class ToolManager:
|
||||
tool = await active_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
if sandbox_available:
|
||||
tool = await self.skill_tool_loader.get_tool(name)
|
||||
if tool:
|
||||
return tool
|
||||
tool = await self.skill_tool_loader.get_tool(name, sandbox_available=sandbox_available)
|
||||
if tool:
|
||||
return tool
|
||||
|
||||
return await self.mcp_tool_loader.get_tool(context, name)
|
||||
|
||||
@@ -310,7 +322,10 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
if sandbox_available and await self.skill_tool_loader.has_tool(name):
|
||||
if await self.skill_tool_loader.has_tool(name, sandbox_available=sandbox_available):
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict):
|
||||
variables['_skill_execution_available'] = sandbox_available
|
||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||
return await self._invoke_tool_with_monitoring(
|
||||
source='skill',
|
||||
@@ -319,6 +334,13 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
recognizes_native = getattr(self.native_tool_loader, 'recognizes_tool', None)
|
||||
is_sandbox_skill_tool = getattr(self.skill_tool_loader, 'is_sandbox_tool', None)
|
||||
if not sandbox_available and (
|
||||
(callable(recognizes_native) and recognizes_native(name) is True)
|
||||
or (callable(is_sandbox_skill_tool) and is_sandbox_skill_tool(name) is True)
|
||||
):
|
||||
return self._sandbox_unavailable_result(name)
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
async def shutdown(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from .manager import SkillManager
|
||||
from .repository import SkillRepository
|
||||
|
||||
__all__ = ['SkillManager']
|
||||
|
||||
__all__ = ['SkillManager', 'SkillRepository']
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ..core import app
|
||||
|
||||
|
||||
class SkillManager:
|
||||
"""Workspace-scoped in-memory view of Box-managed skill packages."""
|
||||
"""Workspace-scoped in-memory view of Core-managed skill packages."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
@@ -63,33 +61,20 @@ class SkillManager:
|
||||
self._skills_by_scope.pop(existing_key, None)
|
||||
self._skills_by_scope[key] = {}
|
||||
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
self.ap.logger.info(
|
||||
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
|
||||
)
|
||||
repository = getattr(self.ap, 'skill_repository', None)
|
||||
if repository is None:
|
||||
self.ap.logger.info('Skill repository unavailable; skill cache will remain empty.')
|
||||
return
|
||||
|
||||
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
|
||||
try:
|
||||
dropped = 0
|
||||
skills: dict[str, dict] = {}
|
||||
for skill_data in await box_service.list_skills(execution_context):
|
||||
for skill_data in await repository.list_skills(execution_context):
|
||||
skill_name = skill_data.get('name')
|
||||
if not skill_name:
|
||||
continue
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if validate_locally and package_root and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" reported by Box runtime but package_root '
|
||||
f'missing on LangBot filesystem ({package_root}); dropping from cache.'
|
||||
)
|
||||
dropped += 1
|
||||
continue
|
||||
skills[skill_name] = skill_data
|
||||
self._skills_by_scope[key] = skills
|
||||
suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
|
||||
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
|
||||
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}')
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from langbot_plugin.skill_store import (
|
||||
SkillRevisionMismatchError,
|
||||
SkillStore,
|
||||
skill_namespace,
|
||||
)
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
|
||||
|
||||
|
||||
class SkillRepository:
|
||||
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
|
||||
|
||||
def __init__(self, ap) -> None:
|
||||
self.ap = ap
|
||||
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
|
||||
self._local_config = (config.get('box') or {}).get('local') or {}
|
||||
self._skills_config = config.get('skills') or {}
|
||||
self._store = SkillStore(self._skills_root())
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _host_root(self) -> str:
|
||||
configured = str(self._local_config.get('host_root') or './data/box').strip()
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
def _skills_root(self) -> str:
|
||||
configured = str(self._skills_config.get('root') or '').strip()
|
||||
if not configured:
|
||||
# Online-upgrade bridge for installations whose persisted config
|
||||
# predates the standalone Skill domain.
|
||||
# TODO(next-major): remove box.local.skills_root fallback.
|
||||
legacy = str(self._local_config.get('skills_root') or '').strip()
|
||||
configured = legacy or 'skills'
|
||||
if not os.path.isabs(configured):
|
||||
configured = os.path.join(self._host_root(), configured)
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
def _default_workspace(self) -> str:
|
||||
configured = str(self._local_config.get('default_workspace') or '').strip()
|
||||
if not configured:
|
||||
configured = os.path.join(self._host_root(), 'default')
|
||||
elif not os.path.isabs(configured):
|
||||
configured = os.path.join(self._host_root(), configured)
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid:
|
||||
raise ValueError('Skill operations require an explicit instance UUID')
|
||||
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise ValueError('Skill operations require a positive placement generation')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
entitlement_revision=getattr(context, 'entitlement_revision', 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _namespace(cls, context: TenantContext) -> str:
|
||||
execution_context = cls._execution_context(context)
|
||||
return skill_namespace(
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
)
|
||||
|
||||
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = self._execution_context(context)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if (
|
||||
binding.instance_uuid != execution_context.instance_uuid
|
||||
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
|
||||
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
|
||||
):
|
||||
raise ValueError('Skill execution context belongs to a stale Workspace placement')
|
||||
return execution_context
|
||||
|
||||
def _workspace_root(self, namespace: str) -> str:
|
||||
return os.path.join(self._default_workspace(), 'tenants', namespace)
|
||||
|
||||
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
namespace = self._namespace(execution_context)
|
||||
|
||||
def invoke():
|
||||
method = getattr(self._store.scoped(namespace), method_name)
|
||||
return method(*args, **kwargs)
|
||||
|
||||
async with self._lock:
|
||||
with blocking_work_scope(f'skill:{namespace}'):
|
||||
return await run_blocking_atomic(invoke)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
return await self._call(context, 'list_skills')
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str, *, snapshot: bool = False) -> dict | None:
|
||||
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
return await self._call(context, 'create_skill', skill)
|
||||
|
||||
async def import_skill_directory(self, context: TenantContext, path: str, skill: dict) -> dict:
|
||||
namespace = self._namespace(context)
|
||||
return await self._call(
|
||||
context,
|
||||
'import_skill_directory',
|
||||
path,
|
||||
skill,
|
||||
source_root=self._workspace_root(namespace),
|
||||
)
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
return await self._call(context, 'update_skill', name, skill)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
await self._call(context, 'delete_skill', name)
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
namespace = self._namespace(context)
|
||||
return await self._call(
|
||||
context,
|
||||
'scan_import_directory',
|
||||
path,
|
||||
source_root=self._workspace_root(namespace),
|
||||
)
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
return await self._call(context, 'read_skill_file', name, path)
|
||||
|
||||
async def list_skill_resources(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
*,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict:
|
||||
return await self._call(
|
||||
context,
|
||||
'list_skill_resources',
|
||||
name,
|
||||
path,
|
||||
False,
|
||||
200,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
async def read_skill_resource(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str,
|
||||
*,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict:
|
||||
return await self._call(
|
||||
context,
|
||||
'read_skill_resource',
|
||||
name,
|
||||
path,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
return await self._call(context, 'write_skill_file', name, path, content)
|
||||
|
||||
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
||||
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
||||
|
||||
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
||||
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
||||
|
||||
|
||||
__all__ = ['SkillRepository', 'SkillRevisionMismatchError']
|
||||
@@ -198,7 +198,7 @@ async def build_heartbeat_payload(
|
||||
except Exception:
|
||||
features['plugin_count'] = -1
|
||||
|
||||
# Skill count (from Box runtime via skill manager)
|
||||
# Skill count (from the Core SkillRepository cache)
|
||||
try:
|
||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||
if skill_mgr is not None:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Python project detection and sandbox-local environment bootstrap helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
|
||||
PYTHON_MANIFEST_FILES = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
|
||||
|
||||
def normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return []
|
||||
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
||||
|
||||
|
||||
def should_prepare_python_env(host_path: str | None) -> bool:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return bool(list_python_manifest_files(normalized_root))
|
||||
|
||||
|
||||
def wrap_python_command_with_env(
|
||||
command: str,
|
||||
*,
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
"""Wrap a command with a reusable sandbox-local Python env bootstrap."""
|
||||
|
||||
writable_state_path = state_path or mount_path
|
||||
bootstrap = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="{writable_state_path}/.venv"
|
||||
_LB_META_DIR="{writable_state_path}/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="{writable_state_path}/.tmp"
|
||||
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "{mount_path}"
|
||||
max_manifest_bytes = 10 * 1024 * 1024
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if os.path.getsize(path) > max_manifest_bytes:
|
||||
raise RuntimeError(
|
||||
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
|
||||
)
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
}},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
|
||||
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
python -m pip install -r "{mount_path}/requirements.txt"
|
||||
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
||||
python -m pip install "{mount_path}"
|
||||
fi
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
{command}
|
||||
"""
|
||||
).strip()
|
||||
return bootstrap + '\n'
|
||||
|
||||
|
||||
__all__ = [
|
||||
'PYTHON_MANIFEST_FILES',
|
||||
'list_python_manifest_files',
|
||||
'normalize_host_path',
|
||||
'should_prepare_python_env',
|
||||
'wrap_python_command_with_env',
|
||||
]
|
||||
@@ -83,7 +83,7 @@ class VersionManager:
|
||||
try:
|
||||
if await self.is_new_version_available():
|
||||
return (
|
||||
'New version available. Update guide: https://link.langbot.app/en/docs/update',
|
||||
'New version available. Update guide: https://langbot.app/docs/en/deploy/update',
|
||||
logging.INFO,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -269,7 +269,7 @@ class InvitationDeliveryService:
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<img src="https://langbot.app/docs/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -323,13 +323,20 @@ monitoring:
|
||||
# Prevent one large Workspace backlog from monopolizing PostgreSQL.
|
||||
# Supports MONITORING__AUTO_CLEANUP__MAX_BATCHES_PER_TABLE_PER_RUN.
|
||||
max_batches_per_table_per_run: 4
|
||||
skills:
|
||||
# Core-owned SkillStore. Skill discovery, activation, resources, CRUD and
|
||||
# revisions remain available when Box is disabled.
|
||||
# TODO(next-major): change the fresh-install default to './data/skills'
|
||||
# after the online-upgrade window for the historical Box path closes.
|
||||
root: './data/box/skills'
|
||||
|
||||
box:
|
||||
# Master switch for the Box sandbox runtime. When false, LangBot does NOT
|
||||
# attempt to connect to a remote Box runtime nor start a local stdio Box
|
||||
# subprocess. Disabling Box also disables every feature that depends on it:
|
||||
# the native sandbox tools (exec/read/write/edit/glob/grep), the activate
|
||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||
# be listed read-only and http/sse MCP servers continue to work.
|
||||
# subprocess. Disabling Box disables execution-backed features: native
|
||||
# sandbox tools (exec/read/write/edit/glob/grep), agent-side skill
|
||||
# registration, and stdio-mode MCP servers. Skill management, activation,
|
||||
# and read-only package resources remain available without Box.
|
||||
enabled: true
|
||||
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||
# isolation. It is never auto-selected and is only for trusted local
|
||||
@@ -378,7 +385,6 @@ box:
|
||||
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
||||
host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path.
|
||||
default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root.
|
||||
skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root.
|
||||
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
"header-name": "",
|
||||
"header-value": "",
|
||||
"timeout": 120,
|
||||
"output-key": "response"
|
||||
"output-key": "response",
|
||||
"response-handling": "reply"
|
||||
},
|
||||
"langflow-api": {
|
||||
"base-url": "http://localhost:7860",
|
||||
|
||||
@@ -475,6 +475,25 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: 'response'
|
||||
- name: response-handling
|
||||
label:
|
||||
en_US: Webhook Response Handling
|
||||
zh_Hans: Webhook 响应处理方式
|
||||
description:
|
||||
en_US: Choose whether LangBot forwards the n8n webhook response to the chat user. Ignore mode requires the n8n Webhook node to use Respond Immediately.
|
||||
zh_Hans: 选择是否将 n8n Webhook 响应转发给聊天用户。忽略模式要求 n8n Webhook 节点使用“立即响应”。
|
||||
type: select
|
||||
required: false
|
||||
default: 'reply'
|
||||
options:
|
||||
- name: reply
|
||||
label:
|
||||
en_US: Forward as chat reply
|
||||
zh_Hans: 转发为聊天回复
|
||||
- name: ignore
|
||||
label:
|
||||
en_US: Ignore response body (asynchronous workflow)
|
||||
zh_Hans: 忽略响应正文(异步工作流)
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: coze API
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Skills API behavior when a workspace plan has no managed sandbox."""
|
||||
"""Skills API behavior is independent from managed sandbox entitlement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,10 +9,7 @@ import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
|
||||
from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementFeatureUnavailableError,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
from langbot.pkg.cloud.entitlements import EntitlementUnavailableError
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
@@ -32,10 +29,7 @@ async def skills_api():
|
||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
|
||||
application.skill_service.list_skills = AsyncMock(
|
||||
side_effect=EntitlementFeatureUnavailableError(
|
||||
'managed_sandbox',
|
||||
entitlement_revision=1,
|
||||
)
|
||||
return_value=[{'name': 'docs-only', 'description': 'No execution required'}]
|
||||
)
|
||||
|
||||
quart_app = quart.Quart(__name__)
|
||||
@@ -45,7 +39,7 @@ async def skills_api():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
|
||||
async def test_list_skills_remains_available_without_managed_sandbox(skills_api):
|
||||
application, client = skills_api
|
||||
response = await client.get(
|
||||
'/api/v1/skills',
|
||||
@@ -57,7 +51,7 @@ async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['data'] == {'skills': []}
|
||||
assert payload['data'] == {'skills': [{'name': 'docs-only', 'description': 'No execution required'}]}
|
||||
application.skill_service.list_skills.assert_awaited_once()
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ from langbot.pkg.cloud.entitlements import (
|
||||
EntitlementSnapshot,
|
||||
EntitlementUnavailableError,
|
||||
)
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
from langbot.pkg.skill.repository import SkillRepository
|
||||
from langbot.pkg.provider.tools.loaders import skill as skill_loader
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@@ -54,7 +57,7 @@ class _AdmissionBackend(BaseSandboxBackend):
|
||||
'mount_isolation': True,
|
||||
'network_isolation': True,
|
||||
'hard_workspace_quota': True,
|
||||
'hard_skill_storage_quota': True,
|
||||
'hard_read_only_mount_quota': True,
|
||||
'bounded_ephemeral_storage': True,
|
||||
'inode_quota': True,
|
||||
}
|
||||
@@ -230,9 +233,18 @@ async def _stack(tmp_path):
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True),
|
||||
entitlement_resolver=EntitlementResolver('instance-a', entitlements),
|
||||
workspace_service=workspace_service,
|
||||
instance_config=SimpleNamespace(data={'box': box_config, 'system': {'limitation': {}}}),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'skills': {'root': str(shared_root / 'skills')},
|
||||
'box': box_config,
|
||||
'system': {'limitation': {}},
|
||||
}
|
||||
),
|
||||
)
|
||||
app.skill_repository = SkillRepository(app)
|
||||
app.skill_mgr = SkillManager(app)
|
||||
service = BoxService(app, client=client)
|
||||
app.box_service = service
|
||||
await service.initialize()
|
||||
return service, runtime, backend, entitlements, server_task, client_task
|
||||
|
||||
@@ -312,7 +324,7 @@ async def test_two_workspaces_get_isolated_physical_sessions_and_paths(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tmp_path):
|
||||
async def test_cloud_core_skills_mount_generically_and_do_not_require_box_entitlement(tmp_path):
|
||||
service, runtime, backend, entitlements, server_task, client_task = await _stack(tmp_path)
|
||||
first = _context('workspace-a')
|
||||
second = _context('workspace-b')
|
||||
@@ -324,32 +336,35 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
|
||||
managed=False,
|
||||
)
|
||||
try:
|
||||
private = await service.create_skill(
|
||||
repository = service.ap.skill_repository
|
||||
await repository.create_skill(
|
||||
second,
|
||||
{
|
||||
'name': 'private',
|
||||
'instructions': 'workspace-b secret',
|
||||
},
|
||||
)
|
||||
own_skill = await service.create_skill(
|
||||
own_skill = await repository.create_skill(
|
||||
first,
|
||||
{
|
||||
'name': 'runner',
|
||||
'instructions': 'Run scripts/main.py',
|
||||
},
|
||||
)
|
||||
await service.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
|
||||
await service.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
|
||||
refreshed_skill = await service.get_skill(first, 'runner')
|
||||
await repository.write_skill_file(first, 'runner', 'scripts/main.py', "print('ok')")
|
||||
await repository.write_skill_file(first, 'runner', 'requirements.txt', 'requests==2.32.0\n')
|
||||
refreshed_skill = await repository.get_skill(first, 'runner')
|
||||
assert refreshed_skill is not None
|
||||
assert refreshed_skill['python_project'] is True
|
||||
await service.ap.skill_mgr.reload_skills(first)
|
||||
query = _query(first, 91)
|
||||
await service.execute_tool(
|
||||
{
|
||||
'command': 'python /workspace/.skills/runner/scripts/main.py',
|
||||
'workdir': '/workspace/.skills/runner',
|
||||
},
|
||||
_query(first, 91),
|
||||
skill_name='runner',
|
||||
query,
|
||||
read_only_mounts=skill_loader.build_execution_mounts(service.ap, query),
|
||||
)
|
||||
|
||||
mounted_spec = backend.started_specs[-1]
|
||||
@@ -358,20 +373,14 @@ async def test_cloud_skills_reject_host_paths_and_require_managed_entitlement(tm
|
||||
assert mounted_spec.extra_mounts[0].mount_path == '/workspace/.skills/runner'
|
||||
assert mounted_spec.extra_mounts[0].mode.value == 'ro'
|
||||
|
||||
with pytest.raises(BoxAdmissionError, match='Scanning arbitrary host'):
|
||||
await service.scan_skill_directory(first, private['package_root'])
|
||||
with pytest.raises(BoxAdmissionError, match='package_root is runtime-owned'):
|
||||
await service.create_skill(
|
||||
first,
|
||||
{
|
||||
'name': 'stolen',
|
||||
'package_root': private['package_root'],
|
||||
},
|
||||
)
|
||||
|
||||
assert await service.get_skill(first, 'private') is None
|
||||
assert await repository.get_skill(first, 'private') is None
|
||||
await repository.create_skill(
|
||||
ineligible,
|
||||
{'name': 'docs-only', 'instructions': 'Read this without Box.'},
|
||||
)
|
||||
assert [skill['name'] for skill in await repository.list_skills(ineligible)] == ['docs-only']
|
||||
with pytest.raises(EntitlementUnavailableError):
|
||||
await service.list_skills(ineligible)
|
||||
await service.execute_tool({'command': 'true'}, _query(ineligible, 92))
|
||||
finally:
|
||||
server_task.cancel()
|
||||
client_task.cancel()
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import sqlalchemy
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -449,10 +450,58 @@ class TestBotServiceCreateBot:
|
||||
insert_statement = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
||||
insert_values = insert_statement.compile().params
|
||||
assert insert_values['workspace_uuid'] == WORKSPACE_UUID
|
||||
assert insert_values['use_pipeline_uuid'] == 'default-pipeline-uuid'
|
||||
assert insert_values['use_pipeline_name'] == 'Default Pipeline'
|
||||
assert bot_uuid is not None # Verify UUID was returned
|
||||
|
||||
async def test_create_bot_rolls_back_insert_when_load_bot_fails(self):
|
||||
"""Deletes the inserted row when the adapter fails to load.
|
||||
|
||||
Regression: a failing adapter constructor (e.g. KeyError on a missing
|
||||
optional credential key) used to leave a permanently disabled orphan
|
||||
bot in the DB — the insert was already committed and the HTTP layer
|
||||
surfaced a 500 without any cleanup.
|
||||
"""
|
||||
# Setup
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
|
||||
ap.platform_mgr = SimpleNamespace()
|
||||
ap.platform_mgr.load_bot = AsyncMock(side_effect=KeyError('token'))
|
||||
|
||||
pipeline_result = Mock()
|
||||
pipeline_result.first = Mock(return_value=None)
|
||||
bot_result = Mock()
|
||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
||||
|
||||
executed_statements = []
|
||||
|
||||
async def mock_execute(query):
|
||||
executed_statements.append(query)
|
||||
if len(executed_statements) <= 2:
|
||||
return pipeline_result # 1: limitation bots query, 2: pipeline query
|
||||
if len(executed_statements) == 3:
|
||||
return Mock() # insert
|
||||
return bot_result # get_bot after insert
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Bot'})
|
||||
|
||||
service = BotService(ap)
|
||||
|
||||
# Execute & Verify: the adapter error propagates
|
||||
with pytest.raises(KeyError, match='token'):
|
||||
await service.create_bot(
|
||||
WORKSPACE_UUID, {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
|
||||
)
|
||||
|
||||
# And the inserted row is rolled back via a DELETE on the new uuid
|
||||
# (no limitation query runs because max_bots=-1)
|
||||
assert len(executed_statements) == 4 # pipeline select, insert, bot select, delete
|
||||
delete_statement = executed_statements[-1]
|
||||
assert isinstance(delete_statement, sqlalchemy.sql.dml.Delete)
|
||||
compiled = delete_statement.compile()
|
||||
assert compiled.params['uuid_1'] is not None
|
||||
|
||||
|
||||
class TestBotServiceUpdateBot:
|
||||
"""Tests for update_bot method."""
|
||||
|
||||
@@ -41,6 +41,7 @@ from langbot_plugin.box.security import (
|
||||
from langbot_plugin.entities.io.context import ActionContext
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot.pkg.provider.tools.loaders import skill as skill_loader
|
||||
|
||||
_UTC = dt.timezone.utc
|
||||
_CONTEXT = ExecutionContext(
|
||||
@@ -301,7 +302,7 @@ class TestSharesFilesystemWithBox:
|
||||
- stdio (local child process) → shared filesystem → True
|
||||
- WebSocket (Docker / sidecar / --standalone-box / remote) → separated → False
|
||||
|
||||
This drives whether LangBot validates Box-reported skill paths locally.
|
||||
This drives whether LangBot can safely perform local workspace operations.
|
||||
Getting it wrong silently drops every skill in separated deployments.
|
||||
"""
|
||||
|
||||
@@ -338,7 +339,7 @@ class TestSharesFilesystemWithBox:
|
||||
|
||||
def test_false_when_client_injected_without_connector(self):
|
||||
# Injected client (no connector) → unknown topology → conservative False
|
||||
# so LangBot never wrongly drops Box-reported skills.
|
||||
# so LangBot does not assume a shared local filesystem.
|
||||
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
|
||||
|
||||
assert service._runtime_connector is None
|
||||
@@ -552,7 +553,6 @@ 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()
|
||||
@@ -565,16 +565,14 @@ async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
||||
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_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
|
||||
async def test_cloud_box_service_reconnect_restores_runtime_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
app = make_app(Mock())
|
||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
service._cloud_managed = True
|
||||
connector = Mock()
|
||||
@@ -587,7 +585,6 @@ async def test_cloud_box_service_reconnect_does_not_reload_unscoped_skills(
|
||||
|
||||
connector.reconnect.assert_awaited_once()
|
||||
service._verify_cloud_runtime.assert_awaited_once()
|
||||
app.skill_mgr.reload_skills.assert_not_awaited()
|
||||
assert service.available is True
|
||||
|
||||
|
||||
@@ -1941,7 +1938,7 @@ def test_disconnect_callback_does_not_schedule_without_running_event_loop():
|
||||
assert service._reconnecting is False
|
||||
|
||||
|
||||
class TestBuildSkillExtraMounts:
|
||||
class TestBuildSkillExecutionMounts:
|
||||
"""Robustness of skill mount construction against a stale skill cache.
|
||||
|
||||
The three sandbox backends behave inconsistently when a skill's
|
||||
@@ -1951,16 +1948,10 @@ class TestBuildSkillExtraMounts:
|
||||
the backend never sees a bad mount.
|
||||
"""
|
||||
|
||||
def _make_service(self, logger, skills, *, shares_filesystem=True):
|
||||
def _make_app(self, logger, skills):
|
||||
app = make_app(logger)
|
||||
app.skill_mgr = SimpleNamespace(skills=skills, get_skills=Mock(return_value=skills))
|
||||
client = Mock(spec=BoxRuntimeClient)
|
||||
service = BoxService(app, client=client)
|
||||
# Tests construct BoxService with an injected client (no connector), so
|
||||
# set the topology explicitly. Most cases exercise the shared-fs (local
|
||||
# stdio) path where local package_root validation applies.
|
||||
service._shares_filesystem_with_box_override = shares_filesystem
|
||||
return service
|
||||
return app
|
||||
|
||||
def test_skips_skill_with_missing_package_root(self):
|
||||
logger = Mock()
|
||||
@@ -1969,16 +1960,16 @@ class TestBuildSkillExtraMounts:
|
||||
'alive': {'name': 'alive', 'package_root': live_dir},
|
||||
'ghost': {'name': 'ghost', 'package_root': '/nonexistent/path/should/never/exist'},
|
||||
}
|
||||
service = self._make_service(logger, skills)
|
||||
app = self._make_app(logger, skills)
|
||||
query = make_query()
|
||||
|
||||
mounts = service.build_skill_extra_mounts(query)
|
||||
mounts = skill_loader.build_execution_mounts(app, query)
|
||||
|
||||
assert mounts == [
|
||||
{
|
||||
'host_path': live_dir,
|
||||
'mount_path': '/workspace/.skills/alive',
|
||||
'mode': 'rw',
|
||||
'mode': 'ro',
|
||||
}
|
||||
]
|
||||
# Warning logged so operators can see what was dropped
|
||||
@@ -1987,27 +1978,19 @@ class TestBuildSkillExtraMounts:
|
||||
for call in logger.warning.call_args_list
|
||||
)
|
||||
|
||||
def test_trusts_box_paths_when_filesystem_not_shared(self):
|
||||
"""In separated deployments (Docker Compose, k8s sidecar,
|
||||
--standalone-box, remote endpoint) the Box runtime owns its own
|
||||
filesystem. package_root values it reports are NOT resolvable on the
|
||||
LangBot side, so LangBot must trust them rather than dropping every
|
||||
skill via a local isdir() check."""
|
||||
def test_rejects_missing_core_paths_when_filesystem_not_shared(self):
|
||||
"""Core owns package paths even when Box is a separate process."""
|
||||
logger = Mock()
|
||||
skills = {
|
||||
'a': {'name': 'a', 'package_root': '/box/skills/a'},
|
||||
'b': {'name': 'b', 'package_root': '/box/skills/b'},
|
||||
}
|
||||
service = self._make_service(logger, skills, shares_filesystem=False)
|
||||
app = self._make_app(logger, skills)
|
||||
|
||||
mounts = service.build_skill_extra_mounts(make_query())
|
||||
mounts = skill_loader.build_execution_mounts(app, make_query())
|
||||
|
||||
assert mounts == [
|
||||
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'rw'},
|
||||
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'rw'},
|
||||
]
|
||||
# No skill is dropped, so no "missing" warning should be logged.
|
||||
assert not any('package_root missing' in str(call.args[0]) for call in logger.warning.call_args_list)
|
||||
assert mounts == []
|
||||
assert len(logger.warning.call_args_list) == 2
|
||||
|
||||
def test_skips_skill_with_empty_package_root(self):
|
||||
logger = Mock()
|
||||
@@ -2015,25 +1998,23 @@ class TestBuildSkillExtraMounts:
|
||||
'no_root': {'name': 'no_root', 'package_root': ''},
|
||||
'whitespace': {'name': 'whitespace', 'package_root': ' '},
|
||||
}
|
||||
service = self._make_service(logger, skills)
|
||||
app = self._make_app(logger, skills)
|
||||
|
||||
assert service.build_skill_extra_mounts(make_query()) == []
|
||||
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||
|
||||
def test_empty_package_root_skipped_even_when_not_shared(self):
|
||||
"""An empty package_root is always invalid regardless of topology."""
|
||||
logger = Mock()
|
||||
skills = {'no_root': {'name': 'no_root', 'package_root': ''}}
|
||||
service = self._make_service(logger, skills, shares_filesystem=False)
|
||||
app = self._make_app(logger, skills)
|
||||
|
||||
assert service.build_skill_extra_mounts(make_query()) == []
|
||||
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||
|
||||
def test_returns_empty_when_no_skill_manager(self):
|
||||
logger = Mock()
|
||||
app = make_app(logger)
|
||||
# no skill_mgr attribute
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
|
||||
assert service.build_skill_extra_mounts(make_query()) == []
|
||||
assert skill_loader.build_execution_mounts(app, make_query()) == []
|
||||
|
||||
|
||||
# ── Attachment passthrough (inbound / outbound) ─────────────────────────────
|
||||
|
||||
@@ -13,8 +13,8 @@ from langbot.pkg.box.workspace import (
|
||||
classify_python_workspace,
|
||||
infer_workspace_host_path,
|
||||
rewrite_mounted_path,
|
||||
wrap_python_command_with_env,
|
||||
)
|
||||
from langbot.pkg.utils.python_workspace import wrap_python_command_with_env
|
||||
|
||||
|
||||
_CONTEXT = ExecutionContext(
|
||||
|
||||
@@ -55,7 +55,7 @@ finally:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
def make_runner(output_key: str = 'response', response_handling: str = 'reply') -> N8nServiceAPIRunner:
|
||||
ap = Mock()
|
||||
ap.logger = Mock()
|
||||
pipeline_config = {
|
||||
@@ -63,6 +63,7 @@ def make_runner(output_key: str = 'response') -> N8nServiceAPIRunner:
|
||||
'n8n-service-api': {
|
||||
'webhook-url': 'http://test-n8n/webhook',
|
||||
'output-key': output_key,
|
||||
'response-handling': response_handling,
|
||||
'auth-type': 'none',
|
||||
}
|
||||
}
|
||||
@@ -287,6 +288,7 @@ def make_http_session_mock(response_bytes: bytes, status: int = 200):
|
||||
"""Mock httpclient.get_session() returning a session whose post() yields response_bytes."""
|
||||
mock_response = make_mock_response([response_bytes], status=status)
|
||||
mock_response.status = status
|
||||
mock_response.headers = {}
|
||||
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
@@ -314,6 +316,91 @@ async def test_call_webhook_nonstream_adapter_plain_json():
|
||||
assert results[0].content == 'result text'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', [200, 201, 202, 204])
|
||||
@pytest.mark.parametrize(
|
||||
'response_body',
|
||||
[
|
||||
b'{"message":"Workflow was started"}',
|
||||
b'{"response":"must not be forwarded"}',
|
||||
b'plain acknowledgement',
|
||||
],
|
||||
)
|
||||
async def test_call_webhook_ignore_response_body(response_body: bytes, status: int):
|
||||
"""Ignore mode accepts any HTTP 2xx response without emitting chat output."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(response_body, status=status)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
results = []
|
||||
async for message in runner._call_webhook(query):
|
||||
results.append(message)
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_ignore_releases_without_reading_response_body():
|
||||
"""Ignore mode returns after the success status without waiting for the body."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
mock_response = make_mock_response([], status=202)
|
||||
mock_response.headers = {}
|
||||
mock_response.release = Mock()
|
||||
|
||||
async def fail_if_read(_size):
|
||||
raise AssertionError('ignore mode must not read the response body')
|
||||
yield b''
|
||||
|
||||
mock_response.content.iter_chunked = fail_if_read
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session = Mock()
|
||||
mock_session.post = Mock(return_value=mock_cm)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=mock_session):
|
||||
results = [message async for message in runner._call_webhook(query)]
|
||||
|
||||
assert results == []
|
||||
mock_response.release.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status', [201, 202, 204])
|
||||
async def test_call_webhook_reply_mode_preserves_http_200_contract(status: int):
|
||||
"""Reply mode remains backward compatible and rejects non-200 statuses."""
|
||||
runner = make_runner(response_handling='reply')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(b'', status=status)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
with pytest.raises(N8nAPIError, match=f'n8n webhook call failed: {status}'):
|
||||
async for _ in runner._call_webhook(query):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_ignore_mode_preserves_http_error():
|
||||
"""Ignore mode must not swallow a failed n8n webhook response."""
|
||||
runner = make_runner(response_handling='ignore')
|
||||
query = make_query(is_stream=False)
|
||||
http_session = make_http_session_mock(b'{"error":"unavailable"}', status=500)
|
||||
|
||||
with patch('langbot.pkg.provider.runners.n8nsvapi.httpclient.get_session', return_value=http_session):
|
||||
with pytest.raises(N8nAPIError, match='n8n webhook call exception'):
|
||||
async for _ in runner._call_webhook(query):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_response_handling_is_rejected():
|
||||
"""Configuration errors should fail fast instead of silently changing reply behavior."""
|
||||
with pytest.raises(ValueError, match='Invalid n8n response-handling'):
|
||||
make_runner(response_handling='unexpected')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_webhook_stream_adapter_stream_format():
|
||||
"""Stream adapter + stream format → MessageChunks, last is_final."""
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for WecomAdapter.send_message content-key handling."""
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.pkg.platform.sources.wecom import WecomAdapter
|
||||
|
||||
|
||||
class StubWecomClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_media_id(self, msg):
|
||||
return 'MEDIA_ID_123'
|
||||
|
||||
async def send_private_msg(self, user_id, agent_id, text):
|
||||
self.calls.append(('text', user_id, agent_id, text))
|
||||
|
||||
async def send_image(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('image', user_id, agent_id, media_id))
|
||||
|
||||
async def send_voice(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('voice', user_id, agent_id, media_id))
|
||||
|
||||
async def send_file(self, user_id, agent_id, media_id):
|
||||
self.calls.append(('file', user_id, agent_id, media_id))
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
adapter = WecomAdapter.model_construct(bot=StubWecomClient())
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('part', 'expected_type'),
|
||||
[
|
||||
(platform_message.Image(url='https://example.com/x.jpg'), 'image'),
|
||||
(platform_message.Voice(url='https://example.com/x.amr'), 'voice'),
|
||||
(platform_message.File(url='https://example.com/x.pdf', name='x.pdf'), 'file'),
|
||||
],
|
||||
)
|
||||
async def test_send_message_dispatches_media_by_id(part, expected_type):
|
||||
adapter = _make_adapter()
|
||||
chain = platform_message.MessageChain([part])
|
||||
|
||||
await adapter.send_message('person', 'USER1|1000001', chain)
|
||||
|
||||
assert adapter.bot.calls == [(expected_type, 'USER1', 1000001, 'MEDIA_ID_123')]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_text_still_works():
|
||||
adapter = _make_adapter()
|
||||
chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
|
||||
await adapter.send_message('person', 'USER1|1000001', chain)
|
||||
|
||||
assert adapter.bot.calls == [('text', 'USER1', 1000001, 'hello')]
|
||||
@@ -67,15 +67,10 @@ def _make_skill_data(
|
||||
|
||||
|
||||
class TestSkillManagerCache:
|
||||
"""The Box runtime is the only source of truth — SkillManager just holds
|
||||
an in-memory cache populated by ``reload_skills``. There is no local
|
||||
filesystem reader anymore."""
|
||||
"""SkillManager caches the Core-owned SkillRepository catalog."""
|
||||
|
||||
def test_refresh_skill_from_disk_reports_cache_presence(self):
|
||||
"""Box is the only source of truth for skill content. refresh_skill_from_disk
|
||||
now just reports whether the skill is still in the in-memory cache —
|
||||
the actual content refresh is driven by SkillService awaiting
|
||||
``reload_skills`` after every Box mutation."""
|
||||
"""Disk mutations are reflected by an explicit repository reload."""
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
@@ -92,67 +87,26 @@ class TestSkillManagerCache:
|
||||
assert mgr.refresh_skill_from_disk(_CONTEXT, '') is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_drops_box_skills_with_missing_package_root(self):
|
||||
"""When LangBot shares a filesystem with Box (local stdio mode) and Box
|
||||
reports a skill whose package_root is gone from that shared filesystem,
|
||||
the cache must drop it instead of keeping a stale entry that would later
|
||||
produce a bad mount."""
|
||||
async def test_reload_skills_uses_repository_when_box_is_disabled(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
with tempfile.TemporaryDirectory() as live_dir:
|
||||
ghost_dir = os.path.join(live_dir, '_does_not_exist')
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=True,
|
||||
list_skills=AsyncMock(
|
||||
return_value=[
|
||||
_make_skill_data(name='alive', package_root=live_dir),
|
||||
_make_skill_data(name='ghost', package_root=ghost_dir),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = box_service
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
await mgr.reload_skills(_CONTEXT)
|
||||
|
||||
assert list(mgr.get_skills(_CONTEXT)) == ['alive']
|
||||
# Warning fired with the dropped skill name so operators can see it.
|
||||
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
|
||||
assert any('ghost' in msg and 'package_root missing' in msg for msg in warning_messages)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_trusts_box_paths_when_filesystem_not_shared(self):
|
||||
"""In separated deployments (Docker Compose, k8s sidecar,
|
||||
--standalone-box, remote endpoint) the package_root reported by Box
|
||||
lives on the Box runtime's filesystem and is not resolvable on the
|
||||
LangBot side. The cache must keep every Box-reported skill rather than
|
||||
dropping them all via a local isdir() check."""
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
repository = SimpleNamespace(
|
||||
list_skills=AsyncMock(
|
||||
return_value=[
|
||||
_make_skill_data(name='alpha', package_root='/box/skills/alpha'),
|
||||
_make_skill_data(name='beta', package_root='/box/skills/beta'),
|
||||
_make_skill_data(name='alpha', package_root='/skills/alpha'),
|
||||
_make_skill_data(name='beta', package_root='/skills/beta'),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = box_service
|
||||
ap.box_service = SimpleNamespace(available=False, enabled=False)
|
||||
ap.skill_repository = repository
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
await mgr.reload_skills(_CONTEXT)
|
||||
|
||||
assert sorted(mgr.get_skills(_CONTEXT)) == ['alpha', 'beta']
|
||||
# No skill dropped → no "package_root missing" warning.
|
||||
warning_messages = [str(call.args[0]) for call in ap.logger.warning.call_args_list]
|
||||
assert not any('package_root missing' in msg for msg in warning_messages)
|
||||
repository.list_skills.assert_awaited_once_with(_CONTEXT)
|
||||
|
||||
|
||||
class TestSkillActivationHelper:
|
||||
@@ -322,7 +276,7 @@ class TestSkillPathHelpers:
|
||||
|
||||
|
||||
class TestSkillToolLoader:
|
||||
"""The skill tool surface is now just ``activate`` + ``register_skill``.
|
||||
"""Skill activation and resources are independent from sandbox execution.
|
||||
|
||||
The legacy CRUD authoring tools (create/list/get/update/delete/
|
||||
import_skill_from_directory/reload_skills) were removed; skill CRUD is
|
||||
@@ -338,8 +292,11 @@ class TestSkillToolLoader:
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
|
||||
skill = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Step 1')
|
||||
skill['revision'] = 'sha256:demo'
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'demo': skill})
|
||||
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
|
||||
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
query = _make_query()
|
||||
@@ -348,9 +305,13 @@ class TestSkillToolLoader:
|
||||
|
||||
assert result['activated'] is True
|
||||
assert result['skill_name'] == 'demo'
|
||||
assert result['mount_path'] == '/workspace/.skills/demo'
|
||||
assert result['mount_path'] is None
|
||||
assert result['revision'] == 'sha256:demo'
|
||||
assert result['capabilities']['resources_readable'] is True
|
||||
assert result['capabilities']['execution_available'] is False
|
||||
assert result['activated_skill_names'] == ['demo']
|
||||
assert 'Step 1' in result['content']
|
||||
assert '<package-root>' not in result['content']
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -384,7 +345,11 @@ class TestSkillToolLoader:
|
||||
os.makedirs(repo_dir)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
ap.box_service = SimpleNamespace(
|
||||
default_workspace=tmpdir,
|
||||
available=True,
|
||||
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
|
||||
)
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory_async=AsyncMock(
|
||||
return_value={
|
||||
@@ -394,7 +359,7 @@ class TestSkillToolLoader:
|
||||
'instructions': 'Do work',
|
||||
}
|
||||
),
|
||||
create_skill=AsyncMock(
|
||||
import_skill_directory=AsyncMock(
|
||||
return_value=_make_skill_data(name='cloned-skill', package_root=os.path.realpath(repo_dir))
|
||||
),
|
||||
)
|
||||
@@ -407,14 +372,14 @@ class TestSkillToolLoader:
|
||||
)
|
||||
|
||||
ap.skill_service.scan_directory_async.assert_awaited_once_with(_CONTEXT, os.path.realpath(repo_dir))
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
ap.skill_service.import_skill_directory.assert_awaited_once_with(
|
||||
_CONTEXT,
|
||||
os.path.realpath(repo_dir),
|
||||
{
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'package_root': os.path.realpath(repo_dir),
|
||||
},
|
||||
)
|
||||
assert result['registered'] is True
|
||||
@@ -430,8 +395,15 @@ class TestSkillToolLoader:
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
ap.skill_service = SimpleNamespace(scan_directory_async=AsyncMock(), create_skill=AsyncMock())
|
||||
ap.box_service = SimpleNamespace(
|
||||
default_workspace=tmpdir,
|
||||
available=True,
|
||||
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
|
||||
)
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory_async=AsyncMock(),
|
||||
import_skill_directory=AsyncMock(),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
|
||||
@@ -451,7 +423,11 @@ class TestSkillToolLoader:
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap() # no skill_service attribute
|
||||
ap.box_service = SimpleNamespace(default_workspace=tmpdir, available=True)
|
||||
ap.box_service = SimpleNamespace(
|
||||
default_workspace=tmpdir,
|
||||
available=True,
|
||||
require_workspace_sandbox=AsyncMock(return_value=_CONTEXT),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
|
||||
@@ -463,21 +439,22 @@ class TestSkillToolLoader:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_hidden_when_sandbox_backend_unavailable(self):
|
||||
async def test_read_only_tools_remain_when_sandbox_unavailable(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
get_backend_status=AsyncMock(return_value={'backend': {'available': False}}),
|
||||
)
|
||||
ap.skill_repository = SimpleNamespace()
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
assert await loader.get_tools() == []
|
||||
assert await loader.has_tool('activate') is False
|
||||
assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [
|
||||
'activate',
|
||||
'list_skill_resources',
|
||||
'read_skill_resource',
|
||||
]
|
||||
assert await loader.has_tool('activate') is True
|
||||
assert await loader.has_tool('register_skill') is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -486,38 +463,89 @@ class TestSkillToolLoader:
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo')})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
ap.skill_repository = SimpleNamespace()
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
tools = await loader.get_tools()
|
||||
tools = await loader.get_tools(sandbox_available=True)
|
||||
|
||||
assert sorted(tool.name for tool in tools) == ['activate', 'register_skill']
|
||||
assert sorted(tool.name for tool in tools) == [
|
||||
'activate',
|
||||
'list_skill_resources',
|
||||
'read_skill_resource',
|
||||
'register_skill',
|
||||
]
|
||||
assert await loader.has_tool('activate') is True
|
||||
assert await loader.has_tool('register_skill') is True
|
||||
assert await loader.has_tool('register_skill', sandbox_available=True) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_reappear_after_box_backend_recovers(self):
|
||||
async def test_register_skill_appears_after_sandbox_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_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
ap.skill_repository = SimpleNamespace()
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
assert await loader.get_tools() == []
|
||||
assert 'register_skill' not in {tool.name for tool in await loader.get_tools(sandbox_available=False)}
|
||||
assert 'register_skill' in {tool.name for tool in await loader.get_tools(sandbox_available=True)}
|
||||
|
||||
ap.box_service.available = True
|
||||
@pytest.mark.asyncio
|
||||
async def test_resources_require_activation_and_use_pinned_revision(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
|
||||
skill = _make_skill_data(name='demo', instructions='Read references')
|
||||
skill['revision'] = 'sha256:demo'
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'demo': skill})
|
||||
ap.skill_repository = SimpleNamespace(
|
||||
list_skill_resources=AsyncMock(
|
||||
return_value={'entries': [{'path': 'references/a.md'}], 'revision': 'sha256:demo'}
|
||||
),
|
||||
read_skill_resource=AsyncMock(
|
||||
return_value={
|
||||
'path': 'references/a.md',
|
||||
'content': 'reference text',
|
||||
'revision': 'sha256:demo',
|
||||
'mime_type': 'text/markdown',
|
||||
}
|
||||
),
|
||||
)
|
||||
loader = SkillToolLoader(ap)
|
||||
query = _make_query()
|
||||
|
||||
with pytest.raises(ValueError, match='must be activated'):
|
||||
await loader.invoke_tool(
|
||||
'read_skill_resource',
|
||||
{'skill_name': 'demo', 'path': 'references/a.md'},
|
||||
query,
|
||||
)
|
||||
|
||||
register_activated_skill(query, skill)
|
||||
listed = await loader.invoke_tool('list_skill_resources', {'skill_name': 'demo'}, query)
|
||||
read = await loader.invoke_tool(
|
||||
'read_skill_resource',
|
||||
{'skill_name': 'demo', 'path': 'references/a.md', 'revision': 'sha256:demo'},
|
||||
query,
|
||||
)
|
||||
|
||||
assert listed['entries'][0]['path'] == 'references/a.md'
|
||||
assert read['content'] == 'reference text'
|
||||
ap.skill_repository.list_skill_resources.assert_awaited_once_with(
|
||||
_CONTEXT,
|
||||
'demo',
|
||||
'.',
|
||||
expected_revision='sha256:demo',
|
||||
)
|
||||
ap.skill_repository.read_skill_resource.assert_awaited_once_with(
|
||||
_CONTEXT,
|
||||
'demo',
|
||||
'references/a.md',
|
||||
expected_revision='sha256:demo',
|
||||
)
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@@ -551,7 +579,7 @@ class TestNativeToolLoaderSkillPaths:
|
||||
assert result['truncated'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
|
||||
async def test_external_runtime_read_uses_core_skill_repository(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
@@ -563,7 +591,9 @@ class TestNativeToolLoaderSkillPaths:
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
shares_filesystem_with_box=False,
|
||||
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
|
||||
)
|
||||
ap.skill_repository = SimpleNamespace(
|
||||
read_skill_file=AsyncMock(return_value={'content': 'repository-content'})
|
||||
)
|
||||
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
@@ -579,12 +609,12 @@ class TestNativeToolLoaderSkillPaths:
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == 'runtime-owned-content'
|
||||
assert result['content'] == 'repository-content'
|
||||
assert 'core-host-secret' not in repr(result)
|
||||
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
|
||||
ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
|
||||
async def test_core_owned_skill_path_does_not_depend_on_runtime_topology(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
@@ -604,18 +634,20 @@ class TestNativeToolLoaderSkillPaths:
|
||||
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='owned by the Box Runtime'):
|
||||
await loader.invoke_tool(
|
||||
'grep',
|
||||
{
|
||||
'path': '/workspace/.skills/demo',
|
||||
'pattern': 'core-host-secret',
|
||||
},
|
||||
query,
|
||||
)
|
||||
result = await loader.invoke_tool(
|
||||
'grep',
|
||||
{
|
||||
'path': '/workspace/.skills/demo',
|
||||
'pattern': 'core-host-secret',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['total'] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_without_mutating_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
|
||||
@@ -626,11 +658,15 @@ class TestNativeToolLoaderSkillPaths:
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
skill_data = _make_skill_data(name='demo', package_root=tmpdir)
|
||||
ap.skill_mgr = _make_skill_manager(
|
||||
{'demo': skill_data},
|
||||
refresh_skill_from_disk=Mock(),
|
||||
)
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = _make_query(query_id='q1', launcher_type='person', launcher_id='123')
|
||||
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
|
||||
register_activated_skill(query, skill_data)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
@@ -645,8 +681,8 @@ class TestNativeToolLoaderSkillPaths:
|
||||
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
|
||||
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
|
||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
|
||||
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
|
||||
@@ -659,17 +695,18 @@ class TestNativeToolLoaderSkillPaths:
|
||||
shares_filesystem_with_box=False,
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
skill_data = _make_skill_data(
|
||||
name='demo',
|
||||
package_root='/box-runtime/skills/tenants/workspace/demo',
|
||||
python_project=True,
|
||||
)
|
||||
ap.skill_mgr = _make_skill_manager(
|
||||
{'demo': skill_data},
|
||||
refresh_skill_from_disk=Mock(),
|
||||
)
|
||||
loader = NativeToolLoader(ap)
|
||||
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
|
||||
register_activated_skill(
|
||||
query,
|
||||
_make_skill_data(
|
||||
name='demo',
|
||||
package_root='/box-runtime/skills/tenants/workspace/demo',
|
||||
python_project=True,
|
||||
),
|
||||
)
|
||||
register_activated_skill(query, skill_data)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
@@ -686,7 +723,7 @@ class TestNativeToolLoaderSkillPaths:
|
||||
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
|
||||
assert 'root = "/workspace/.skills/demo"' in wrapped
|
||||
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
|
||||
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
|
||||
assert 'skill_name' not in ap.box_service.execute_tool.await_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(self):
|
||||
|
||||
@@ -55,10 +55,20 @@ class StubLoader:
|
||||
for tool in self._tools
|
||||
]
|
||||
|
||||
async def has_tool(self, *args) -> bool:
|
||||
async def get_tool(self, name: str, **_kwargs):
|
||||
return next((tool for tool in self._tools if tool.name == name), None)
|
||||
|
||||
async def has_tool(self, *args, **_kwargs) -> bool:
|
||||
name = args[-1]
|
||||
return any(tool.name == name for tool in self._tools)
|
||||
|
||||
def recognizes_tool(self, name: str) -> bool:
|
||||
return any(tool.name == name for tool in self._tools)
|
||||
|
||||
@staticmethod
|
||||
def is_sandbox_tool(name: str) -> bool:
|
||||
return name == 'register_skill'
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query):
|
||||
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
|
||||
|
||||
@@ -145,7 +155,7 @@ async def test_tool_manager_routes_native_tool_calls():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
|
||||
async def test_tool_manager_keeps_read_only_skill_tools_without_workspace_entitlement():
|
||||
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
manager = ToolManager(SimpleNamespace(box_service=box_service))
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
@@ -156,8 +166,8 @@ async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_enti
|
||||
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
|
||||
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
|
||||
|
||||
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
|
||||
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
|
||||
assert [tool.name for tool in tools] == ['activate', 'plugin_tool', 'mcp_tool']
|
||||
assert [item['name'] for item in catalog] == ['activate', 'plugin_tool', 'mcp_tool']
|
||||
assert box_service.is_workspace_sandbox_available.await_count == 2
|
||||
|
||||
|
||||
@@ -176,9 +186,10 @@ async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocat
|
||||
query_uuid=None,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match='exec'):
|
||||
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
|
||||
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
|
||||
|
||||
assert result['code'] == 'sandbox_unavailable'
|
||||
assert result['tool'] == 'exec'
|
||||
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
|
||||
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ async def test_preproc_injects_skill_index_into_system_prompt():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
app.tool_mgr.get_all_tools.return_value = [SimpleNamespace(name='activate')]
|
||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||
|
||||
@@ -204,6 +205,22 @@ async def test_preproc_injects_skill_index_into_system_prompt():
|
||||
head = query.prompt.messages[0]
|
||||
assert head.role == 'system'
|
||||
assert head.content.endswith(addendum)
|
||||
assert query.variables['_skill_execution_available'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_does_not_advertise_activation_when_tool_is_filtered_out():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
assert addendum not in query.prompt.messages[0].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.skill.repository import SkillRepository, SkillRevisionMismatchError
|
||||
|
||||
|
||||
_CONTEXT = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
|
||||
async def _binding(workspace_uuid, *, expected_generation):
|
||||
return SimpleNamespace(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=expected_generation,
|
||||
)
|
||||
|
||||
|
||||
def _repository(tmp_path) -> SkillRepository:
|
||||
app = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(
|
||||
get_execution_binding=_binding,
|
||||
),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'skills': {'root': str(tmp_path / 'skill-store')},
|
||||
'box': {
|
||||
'enabled': False,
|
||||
'local': {
|
||||
'host_root': str(tmp_path / 'box'),
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
return SkillRepository(app)
|
||||
|
||||
|
||||
def test_repository_prefers_standalone_skill_root(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
|
||||
assert repository._store.root == str((tmp_path / 'skill-store').resolve())
|
||||
|
||||
|
||||
def test_repository_keeps_old_box_root_only_for_online_upgrade(tmp_path):
|
||||
app = SimpleNamespace(
|
||||
workspace_service=SimpleNamespace(get_execution_binding=_binding),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'box': {
|
||||
'local': {
|
||||
'host_root': str(tmp_path / 'box'),
|
||||
'skills_root': 'legacy-skills',
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
repository = SkillRepository(app)
|
||||
|
||||
assert repository._store.root == str((tmp_path / 'box' / 'legacy-skills').resolve())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_crud_and_reads_do_not_require_box(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
|
||||
await repository.create_skill(
|
||||
_CONTEXT,
|
||||
{
|
||||
'name': 'docs-only',
|
||||
'display_name': 'Docs only',
|
||||
'description': 'Read-only guidance',
|
||||
'instructions': 'Read references/guide.md.',
|
||||
},
|
||||
)
|
||||
await repository.write_skill_file(
|
||||
_CONTEXT,
|
||||
'docs-only',
|
||||
'references/guide.md',
|
||||
'# Guide\n\nNo execution needed.',
|
||||
)
|
||||
|
||||
skill = await repository.get_skill(_CONTEXT, 'docs-only', snapshot=True)
|
||||
assert skill is not None
|
||||
assert skill['revision'].startswith('sha256:')
|
||||
assert [item['name'] for item in await repository.list_skills(_CONTEXT)] == ['docs-only']
|
||||
|
||||
listed = await repository.list_skill_resources(
|
||||
_CONTEXT,
|
||||
'docs-only',
|
||||
'references',
|
||||
expected_revision=skill['revision'],
|
||||
)
|
||||
assert listed['entries'][0]['path'] == 'references/guide.md'
|
||||
assert listed['entries'][0]['mime_type'] == 'text/markdown'
|
||||
|
||||
resource = await repository.read_skill_resource(
|
||||
_CONTEXT,
|
||||
'docs-only',
|
||||
'references/guide.md',
|
||||
expected_revision=skill['revision'],
|
||||
)
|
||||
assert resource['content'].startswith('# Guide')
|
||||
assert resource['revision'] == skill['revision']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_rejects_traversal_and_stale_revision(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
await repository.create_skill(
|
||||
_CONTEXT,
|
||||
{'name': 'safe', 'description': 'Safe', 'instructions': 'Use the reference.'},
|
||||
)
|
||||
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'first')
|
||||
skill = await repository.get_skill(_CONTEXT, 'safe', snapshot=True)
|
||||
assert skill is not None
|
||||
|
||||
with pytest.raises(ValueError, match='stay within'):
|
||||
await repository.read_skill_resource(_CONTEXT, 'safe', '../secret.txt')
|
||||
|
||||
await repository.write_skill_file(_CONTEXT, 'safe', 'reference.md', 'second')
|
||||
with pytest.raises(SkillRevisionMismatchError, match='reactivate'):
|
||||
await repository.read_skill_resource(
|
||||
_CONTEXT,
|
||||
'safe',
|
||||
'reference.md',
|
||||
expected_revision=skill['revision'],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_scopes_skills_by_workspace(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
other_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-b',
|
||||
placement_generation=1,
|
||||
)
|
||||
|
||||
await repository.create_skill(_CONTEXT, {'name': 'private', 'instructions': 'A'})
|
||||
|
||||
assert [skill['name'] for skill in await repository.list_skills(_CONTEXT)] == ['private']
|
||||
assert await repository.list_skills(other_context) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_rejects_stale_workspace_placement(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
|
||||
async def stale_binding(workspace_uuid, *, expected_generation):
|
||||
return SimpleNamespace(
|
||||
instance_uuid=_CONTEXT.instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=expected_generation + 1,
|
||||
)
|
||||
|
||||
repository.ap.workspace_service.get_execution_binding = stale_binding
|
||||
with pytest.raises(ValueError, match='stale Workspace placement'):
|
||||
await repository.list_skills(_CONTEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_imports_only_from_the_fenced_workspace(tmp_path):
|
||||
repository = _repository(tmp_path)
|
||||
namespace = repository._namespace(_CONTEXT)
|
||||
source = tmp_path / 'box' / 'default' / 'tenants' / namespace / 'draft'
|
||||
source.mkdir(parents=True)
|
||||
(source / 'SKILL.md').write_text(
|
||||
'---\nname: draft\ndescription: Draft skill\n---\n\nFollow the guide.',
|
||||
encoding='utf-8',
|
||||
)
|
||||
(source / 'guide.md').write_text('Imported resource', encoding='utf-8')
|
||||
|
||||
scanned = await repository.scan_skill_directory(_CONTEXT, str(source))
|
||||
imported = await repository.import_skill_directory(
|
||||
_CONTEXT,
|
||||
str(source),
|
||||
{
|
||||
'name': scanned['name'],
|
||||
'display_name': scanned['display_name'],
|
||||
'description': scanned['description'],
|
||||
'instructions': scanned['instructions'],
|
||||
},
|
||||
)
|
||||
|
||||
assert imported['name'] == 'draft'
|
||||
resource = await repository.read_skill_file(_CONTEXT, 'draft', 'guide.md')
|
||||
assert resource['content'] == 'Imported resource'
|
||||
|
||||
outside = tmp_path / 'outside'
|
||||
outside.mkdir()
|
||||
(outside / 'SKILL.md').write_text('Outside', encoding='utf-8')
|
||||
with pytest.raises(ValueError, match='trusted source root'):
|
||||
await repository.scan_skill_directory(_CONTEXT, str(outside))
|
||||
@@ -23,102 +23,73 @@ def _workspace_service():
|
||||
)
|
||||
|
||||
|
||||
class TestRequireBoxForWrite:
|
||||
"""Box is the only source of truth for skills — there is no local
|
||||
filesystem fallback. Every write and (most) read methods refuse cleanly
|
||||
when the Box runtime is disabled, unreachable, or simply not installed."""
|
||||
class TestSkillRepositoryBoundary:
|
||||
"""Skill management and reads remain available without Box execution."""
|
||||
|
||||
def _ap_with_disabled_box(self):
|
||||
@staticmethod
|
||||
def _ap_with_repository():
|
||||
repository = SimpleNamespace(
|
||||
list_skills=AsyncMock(return_value=[{'name': 'x', 'instructions': 'Do work'}]),
|
||||
get_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:x'}),
|
||||
create_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work'}),
|
||||
update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated'}),
|
||||
delete_skill=AsyncMock(),
|
||||
read_skill_file=AsyncMock(return_value={'path': 'a.txt', 'content': 'hello'}),
|
||||
write_skill_file=AsyncMock(return_value={'path': 'a.txt'}),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=SimpleNamespace(
|
||||
available=False,
|
||||
enabled=False,
|
||||
_connector_error='Box runtime is disabled in config (box.enabled = false)',
|
||||
),
|
||||
)
|
||||
|
||||
def _ap_with_failed_box(self):
|
||||
return SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||
workspace_service=_workspace_service(),
|
||||
box_service=SimpleNamespace(
|
||||
available=False,
|
||||
enabled=True,
|
||||
_connector_error='docker daemon not running',
|
||||
),
|
||||
box_service=SimpleNamespace(available=False, enabled=False),
|
||||
skill_repository=repository,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='disabled in config'):
|
||||
await service.create_skill(_CONTEXT, {'name': 'x'})
|
||||
async def test_list_and_read_work_when_box_disabled(self):
|
||||
ap = self._ap_with_repository()
|
||||
service = SkillService(ap)
|
||||
|
||||
assert await service.list_skills(_CONTEXT) == [{'name': 'x', 'instructions': 'Do work'}]
|
||||
assert await service.read_skill_file(_CONTEXT, 'x', 'a.txt') == {
|
||||
'path': 'a.txt',
|
||||
'content': 'hello',
|
||||
}
|
||||
ap.skill_repository.read_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_failed(self):
|
||||
service = SkillService(self._ap_with_failed_box())
|
||||
with pytest.raises(ValueError, match='docker daemon not running'):
|
||||
await service.create_skill(_CONTEXT, {'name': 'x'})
|
||||
async def test_create_update_and_write_work_when_box_disabled(self):
|
||||
ap = self._ap_with_repository()
|
||||
service = SkillService(ap)
|
||||
|
||||
await service.create_skill(_CONTEXT, {'name': 'x'})
|
||||
await service.update_skill(_CONTEXT, 'x', {'instructions': 'Updated'})
|
||||
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hello')
|
||||
|
||||
ap.skill_repository.create_skill.assert_awaited_once_with(_CONTEXT, {'name': 'x'})
|
||||
ap.skill_repository.update_skill.assert_awaited_once_with(_CONTEXT, 'x', {'instructions': 'Updated'})
|
||||
ap.skill_repository.write_skill_file.assert_awaited_once_with(_CONTEXT, 'x', 'a.txt', 'hello')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Editing a skill requires the Box runtime'):
|
||||
await service.update_skill(_CONTEXT, 'x', {})
|
||||
async def test_get_skill_returns_repository_revision(self):
|
||||
ap = self._ap_with_repository()
|
||||
service = SkillService(ap)
|
||||
|
||||
skill = await service.get_skill(_CONTEXT, 'x')
|
||||
|
||||
assert skill['revision'] == 'sha256:x'
|
||||
ap.skill_repository.get_skill.assert_awaited_once_with(_CONTEXT, 'x', snapshot=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_skill_file_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Editing skill files requires the Box runtime'):
|
||||
await service.write_skill_file(_CONTEXT, 'x', 'a.txt', 'hi')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Installing a skill from GitHub'):
|
||||
await service.install_from_github(
|
||||
_CONTEXT,
|
||||
{'owner': 'o', 'repo': 'r', 'asset_url': 'https://example/x.zip'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_zip_upload_refused_when_box_disabled(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Installing a skill from upload'):
|
||||
await service.install_from_zip_upload(
|
||||
_CONTEXT,
|
||||
file_bytes=b'',
|
||||
filename='x.zip',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_refused_when_box_service_missing_entirely(self):
|
||||
"""No ap.box_service attribute at all (truly minimal setup):
|
||||
Box is the only source of truth, so creation must still refuse."""
|
||||
async def test_missing_repository_is_explicit(self):
|
||||
service = SkillService(
|
||||
SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(reload_skills=AsyncMock()),
|
||||
workspace_service=_workspace_service(),
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match='not initialised'):
|
||||
with pytest.raises(ValueError, match='repository is not initialised'):
|
||||
await service.create_skill(_CONTEXT, {'name': 'x'})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_empty_when_box_unavailable(self):
|
||||
"""list_skills should render an empty surface (not crash) so the
|
||||
skills page can show a banner instead of a broken state."""
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
assert await service.list_skills(_CONTEXT) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_skill_file_refused_when_box_unavailable(self):
|
||||
service = SkillService(self._ap_with_disabled_box())
|
||||
with pytest.raises(ValueError, match='Reading a skill file'):
|
||||
await service.read_skill_file(_CONTEXT, 'x', 'a.txt')
|
||||
|
||||
|
||||
class TestGithubSkillArchiveLimits:
|
||||
@staticmethod
|
||||
|
||||
@@ -113,7 +113,7 @@ async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_lin
|
||||
|
||||
html = service._html("RockChinQ's Workspace", link)
|
||||
|
||||
assert 'https://docs.langbot.app/langbot-logo.png' in html
|
||||
assert 'https://langbot.app/docs/langbot-logo.png' in html
|
||||
assert '>LangBot<' in html
|
||||
assert 'Workspace invitation' in html
|
||||
assert 'Open invitation link' in html
|
||||
|
||||
@@ -2008,7 +2008,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.9"
|
||||
version = "4.10.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2129,7 +2129,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.5.6" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.7" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2196,7 +2196,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.6"
|
||||
version = "0.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2217,9 +2217,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Debug LangBot Frontend
|
||||
|
||||
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
||||
Please refer to the [Development Guide](https://langbot.app/docs/en/develop/dev-config) for more information.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const COMMON_N8N_CONFIG_FIELDS = new Set([
|
||||
'webhook-url',
|
||||
'auth-type',
|
||||
'timeout',
|
||||
'output-key',
|
||||
'response-handling',
|
||||
]);
|
||||
|
||||
export function shouldShowN8nConfigField(
|
||||
fieldName: string,
|
||||
authType: string,
|
||||
): boolean {
|
||||
if (COMMON_N8N_CONFIG_FIELDS.has(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(authType === 'basic' && fieldName.startsWith('basic-')) ||
|
||||
(authType === 'jwt' && fieldName.startsWith('jwt-')) ||
|
||||
(authType === 'header' && fieldName.startsWith('header-'))
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
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 { shouldShowN8nConfigField } from '@/app/home/components/dynamic-form/N8nAuthFieldVisibility';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -181,29 +182,9 @@ export default function N8nAuthFormComponent({
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
// 根据认证类型过滤表单项
|
||||
const filteredConfigList = itemConfigList.filter((config) => {
|
||||
// 始终显示webhook-url、auth-type、timeout和output-key
|
||||
if (
|
||||
['webhook-url', 'auth-type', 'timeout', 'output-key'].includes(
|
||||
config.name,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据认证类型显示相应的表单项
|
||||
if (authType === 'basic' && config.name.startsWith('basic-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'jwt' && config.name.startsWith('jwt-')) {
|
||||
return true;
|
||||
}
|
||||
if (authType === 'header' && config.name.startsWith('header-')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const filteredConfigList = itemConfigList.filter((config) =>
|
||||
shouldShowN8nConfigField(config.name, authType),
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
|
||||
@@ -2195,12 +2195,12 @@ export default function HomeSidebar({
|
||||
localStorage.getItem('langbot_language');
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
window.open(
|
||||
'https://link.langbot.app/zh/docs/guide',
|
||||
'https://langbot.app/docs/zh/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
'https://link.langbot.app/en/docs/guide',
|
||||
'https://langbot.app/docs/en/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/platforms',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/platforms',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/platforms',
|
||||
en_US: 'https://langbot.app/docs/en/usage/platforms/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/platforms/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -62,9 +62,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/pipelines',
|
||||
en_US: 'https://langbot.app/docs/en/usage/pipelines/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/pipelines/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -75,9 +75,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/knowledge',
|
||||
description: t('knowledge.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/knowledge',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/knowledge',
|
||||
en_US: 'https://langbot.app/docs/en/usage/knowledge/readme',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://langbot.app/docs/ja/usage/knowledge/readme',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
@@ -89,9 +89,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/extensions',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
@@ -102,9 +102,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/add-extension',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
|
||||
en_US: 'https://langbot.app/docs/en/plugin/plugin-intro',
|
||||
zh_Hans: 'https://langbot.app/docs/zh/plugin/plugin-intro',
|
||||
ja_JP: 'https://langbot.app/docs/ja/plugin/plugin-intro',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
|
||||
@@ -34,11 +34,11 @@ export default function NewVersionDialog({
|
||||
const getUpdateDocsUrl = () => {
|
||||
const language = i18n.language;
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
return 'https://link.langbot.app/zh/docs/update';
|
||||
return 'https://langbot.app/docs/zh/deploy/update';
|
||||
} else if (language === 'ja-JP') {
|
||||
return 'https://link.langbot.app/ja/docs/update';
|
||||
return 'https://langbot.app/docs/ja/deploy/update';
|
||||
} else {
|
||||
return 'https://link.langbot.app/en/docs/update';
|
||||
return 'https://langbot.app/docs/en/deploy/update';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
|
||||
function InfoTooltip({ label }: { label: string }) {
|
||||
return (
|
||||
@@ -52,11 +50,6 @@ export default function PipelineExtension({
|
||||
pipelineId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
available: boxAvailable,
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
|
||||
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
|
||||
@@ -558,13 +551,9 @@ export default function PipelineExtension({
|
||||
id="enable-all-skills"
|
||||
checked={enableAllSkills}
|
||||
onCheckedChange={handleToggleEnableAllSkills}
|
||||
disabled={!boxAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!boxAvailable && (
|
||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{enableAllSkills ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
||||
@@ -602,7 +591,6 @@ export default function PipelineExtension({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveSkill(skill.name)}
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -616,7 +604,7 @@ export default function PipelineExtension({
|
||||
onClick={handleOpenSkillDialog}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={enableAllSkills || !boxAvailable}
|
||||
disabled={enableAllSkills}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('pipelines.extensions.addSkill')}
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
@@ -36,12 +34,6 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const skill = skills.find((item) => item.id === id);
|
||||
const {
|
||||
available: boxAvailable,
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('skills.createSkill'));
|
||||
@@ -93,23 +85,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
<Button type="submit" form="skill-form" className="shrink-0">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
@@ -184,18 +165,12 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
disabled={!canManage}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Button } from '@/components/ui/button';
|
||||
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function SkillsPage() {
|
||||
@@ -21,12 +19,6 @@ export default function SkillsPage() {
|
||||
const { refreshSkills } = useSidebarData();
|
||||
|
||||
const isCreateView = actionParam === 'create';
|
||||
const {
|
||||
available: boxAvailable,
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailId && !isCreateView) {
|
||||
navigate('/home/add-extension', { replace: true });
|
||||
@@ -65,20 +57,11 @@ export default function SkillsPage() {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
disabled={!boxAvailable || !canManage}
|
||||
>
|
||||
<Button type="submit" form="skill-form" disabled={!canManage}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
|
||||
@@ -76,7 +76,8 @@ const enUS = {
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
and: 'and',
|
||||
dataCollectionPolicy: 'Data Collection Policy',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
@@ -1020,7 +1021,7 @@ const enUS = {
|
||||
mcpToolsScopeTooltip:
|
||||
'Only tools from MCP servers currently allowed in Extensions are shown here.',
|
||||
skillToolsScopeTooltip:
|
||||
'Skill tools are available when LangBot skill service and the Box sandbox backend are ready. They let the agent activate or register skills.',
|
||||
'Skill activation and read-only resources work without Box. Registering a skill from the agent workspace still requires the Box sandbox.',
|
||||
selectTools: 'Select tools',
|
||||
resourcesTitle: 'Resources',
|
||||
resourcesDescription:
|
||||
@@ -1629,9 +1630,9 @@ const enUS = {
|
||||
statusDetail: 'Status',
|
||||
pluginDisabled: 'Plugin system is disabled',
|
||||
boxDisabled:
|
||||
'Box sandbox is disabled in config — sandbox tools, skill add/edit, and stdio MCP are unavailable',
|
||||
'Box sandbox is disabled in config — execution tools and stdio MCP are unavailable',
|
||||
boxUnavailable:
|
||||
'Box sandbox is unavailable — sandbox tools, skill add/edit, and stdio MCP are unavailable',
|
||||
'Box sandbox is unavailable — execution tools and stdio MCP are unavailable',
|
||||
boxRequiredHint:
|
||||
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
|
||||
boxBackend: 'Backend',
|
||||
|
||||
@@ -79,7 +79,8 @@ const esES = {
|
||||
privacyPolicy: 'Política de privacidad',
|
||||
and: 'y',
|
||||
dataCollectionPolicy: 'Política de recopilación de datos',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Cargando...',
|
||||
fieldRequired: 'Este campo es obligatorio',
|
||||
or: 'o',
|
||||
@@ -1043,7 +1044,7 @@ const esES = {
|
||||
mcpToolsScopeTooltip:
|
||||
'Aquí solo se muestran herramientas de servidores MCP permitidos actualmente en Extensiones.',
|
||||
skillToolsScopeTooltip:
|
||||
'Las herramientas de skill aparecen cuando el servicio de skills de LangBot y el backend de sandbox Box están disponibles. Permiten al agente activar o registrar skills.',
|
||||
'La activación y los recursos de solo lectura funcionan sin Box. Registrar un skill desde el espacio del Agent aún requiere el sandbox Box.',
|
||||
selectTools: 'Seleccionar herramientas',
|
||||
resourcesTitle: 'Recursos',
|
||||
resourcesDescription:
|
||||
@@ -1586,9 +1587,9 @@ const esES = {
|
||||
statusDetail: 'Estado',
|
||||
pluginDisabled: 'El sistema de plugins está desactivado',
|
||||
boxDisabled:
|
||||
'El sandbox de Box está desactivado en la configuración — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
|
||||
'El sandbox de Box está desactivado en la configuración — las herramientas de ejecución y MCP stdio no están disponibles',
|
||||
boxUnavailable:
|
||||
'El sandbox de Box no está disponible — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
|
||||
'El sandbox de Box no está disponible — las herramientas de ejecución y MCP stdio no están disponibles',
|
||||
boxRequiredHint:
|
||||
'Esta función requiere el runtime de Box. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime está conectado.',
|
||||
boxBackend: 'Backend',
|
||||
|
||||
@@ -77,7 +77,8 @@ const jaJP = {
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
and: 'および',
|
||||
dataCollectionPolicy: 'データ収集ポリシー',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/ja/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/ja/insight/data-collection-policy',
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
@@ -1025,7 +1026,7 @@ const jaJP = {
|
||||
mcpToolsScopeTooltip:
|
||||
'拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。',
|
||||
skillToolsScopeTooltip:
|
||||
'スキルツールは LangBot のスキルサービスと Box サンドボックスバックエンドが利用可能なときに表示され、Agent がスキルを有効化または登録できるようにします。',
|
||||
'スキルの有効化と読み取り専用リソースには Box は不要です。Agent ワークスペースからの登録には Box サンドボックスが必要です。',
|
||||
selectTools: 'ツールを選択',
|
||||
resourcesTitle: 'リソース',
|
||||
resourcesDescription:
|
||||
@@ -1637,9 +1638,9 @@ const jaJP = {
|
||||
statusDetail: 'ステータス',
|
||||
pluginDisabled: 'プラグインシステムが無効です',
|
||||
boxDisabled:
|
||||
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
||||
'Box サンドボックスは設定で無効化されています — 実行ツールと stdio MCP は利用できません',
|
||||
boxUnavailable:
|
||||
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
||||
'Box サンドボックスは利用できません — 実行ツールと stdio MCP は利用できません',
|
||||
boxRequiredHint:
|
||||
'この機能には Box ランタイムが必要です。設定で有効化(box.enabled = true)し、ランタイムが正常に接続できることを確認してください。',
|
||||
boxBackend: 'バックエンド',
|
||||
|
||||
@@ -76,7 +76,8 @@ const ruRU = {
|
||||
privacyPolicy: 'Политикой конфиденциальности',
|
||||
and: 'и',
|
||||
dataCollectionPolicy: 'Политикой сбора данных',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'Загрузка...',
|
||||
fieldRequired: 'Это поле обязательно для заполнения',
|
||||
or: 'или',
|
||||
@@ -1033,7 +1034,7 @@ const ruRU = {
|
||||
mcpToolsScopeTooltip:
|
||||
'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.',
|
||||
skillToolsScopeTooltip:
|
||||
'Инструменты навыков доступны, когда сервис навыков LangBot и backend песочницы Box готовы. Они позволяют агенту активировать или регистрировать навыки.',
|
||||
'Активация навыков и ресурсы только для чтения работают без Box. Регистрация из рабочей области Agent по-прежнему требует песочницу Box.',
|
||||
selectTools: 'Выбрать инструменты',
|
||||
resourcesTitle: 'Ресурсы',
|
||||
resourcesDescription:
|
||||
@@ -1559,9 +1560,9 @@ const ruRU = {
|
||||
statusDetail: 'Статус',
|
||||
pluginDisabled: 'Система плагинов отключена',
|
||||
boxDisabled:
|
||||
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
||||
'Песочница Box отключена в конфигурации — инструменты выполнения и stdio MCP недоступны',
|
||||
boxUnavailable:
|
||||
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
||||
'Песочница Box недоступна — инструменты выполнения и stdio MCP недоступны',
|
||||
boxRequiredHint:
|
||||
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
|
||||
boxBackend: 'Бэкенд',
|
||||
|
||||
@@ -76,7 +76,8 @@ const thTH = {
|
||||
privacyPolicy: 'นโยบายความเป็นส่วนตัว',
|
||||
and: 'และ',
|
||||
dataCollectionPolicy: 'นโยบายการเก็บรวบรวมข้อมูล',
|
||||
dataCollectionPolicyUrl: 'https://link.langbot.app/en/docs/data-policy',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://langbot.app/docs/en/insight/data-collection-policy',
|
||||
loading: 'กำลังโหลด...',
|
||||
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
|
||||
or: 'หรือ',
|
||||
@@ -1008,7 +1009,7 @@ const thTH = {
|
||||
mcpToolsScopeTooltip:
|
||||
'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย',
|
||||
skillToolsScopeTooltip:
|
||||
'เครื่องมือสกิลจะแสดงเมื่อบริการสกิลของ LangBot และแบ็กเอนด์แซนด์บ็อกซ์ Box พร้อมใช้งาน เพื่อให้ Agent เปิดใช้หรือลงทะเบียนสกิลได้',
|
||||
'การเปิดใช้สกิลและทรัพยากรแบบอ่านอย่างเดียวทำงานได้โดยไม่ต้องใช้ Box ส่วนการลงทะเบียนจาก workspace ของ Agent ยังต้องใช้ sandbox Box',
|
||||
selectTools: 'เลือกเครื่องมือ',
|
||||
resourcesTitle: 'ทรัพยากร',
|
||||
resourcesDescription:
|
||||
@@ -1528,9 +1529,9 @@ const thTH = {
|
||||
statusDetail: 'สถานะ',
|
||||
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
|
||||
boxDisabled:
|
||||
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
||||
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
|
||||
boxUnavailable:
|
||||
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
||||
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือเรียกใช้และ stdio MCP ใช้งานไม่ได้',
|
||||
boxRequiredHint:
|
||||
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
|
||||
boxBackend: 'แบ็กเอนด์',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user