mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-06 01:27:14 +00:00
feat(skill): use SDK store without box execution
This commit is contained in:
+3
-2
@@ -169,14 +169,15 @@ 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/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/skill/repository.py` is the thin async/Workspace adapter over the Plugin SDK's execution-independent `SkillStore`; it preserves the existing `data/box/skills` layout shared with Box for optional execution mounts.
|
||||
- `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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
│ │ Action RPC (stdio 或 WebSocket) │
|
||||
│ │
|
||||
│ SkillManager (skill_mgr) │
|
||||
│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │
|
||||
│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -171,11 +171,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 个)。
|
||||
@@ -343,23 +342,24 @@ 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 与 Box 使用同一套存储和 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/`。
|
||||
`langbot_plugin.box.skill_store.BoxSkillStore` 仅保留为旧 Box 配置到通用 `SkillStore` 的兼容适配器,不再拥有存储实现。GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件继续存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),执行时只读挂载到 `/workspace/.skills/`。
|
||||
|
||||
### 3.8 Security (`box/security.py`, 52 行)
|
||||
|
||||
@@ -463,7 +463,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 +480,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 销毁流程
|
||||
|
||||
@@ -508,9 +508,9 @@ 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 +522,7 @@ box:
|
||||
image: '' # 覆盖 profile 默认 image
|
||||
host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径
|
||||
default_workspace: '' # 默认 '<host_root>/default'
|
||||
skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root)
|
||||
skills_root: 'skills' # Core 管理且与 Box 共享的 skill 包目录
|
||||
allowed_mount_roots: # 默认 ['<host_root>']
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
@@ -561,16 +561,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 适配层,保持原 `data/box/skills/tenants/...` 布局,升级时无需移动已安装 Skill。Box 只在执行发生时消费同一份只读 package revision。
|
||||
|
||||
### Pipeline 配置 (templates/metadata/pipeline/ai.yaml)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -711,7 +711,7 @@ class BoxService:
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||
'mode': 'rw',
|
||||
'mode': 'ro',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
@@ -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 =======
|
||||
|
||||
|
||||
@@ -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} '
|
||||
|
||||
@@ -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(
|
||||
@@ -1123,7 +1127,8 @@ 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)
|
||||
@@ -1136,7 +1141,7 @@ else:
|
||||
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 +1149,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 +1183,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 +1222,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 +1240,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,
|
||||
|
||||
@@ -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,192 @@
|
||||
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._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._local_config.get('skills_root') or 'skills').strip()
|
||||
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']
|
||||
@@ -326,10 +326,10 @@ monitoring:
|
||||
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 +378,7 @@ 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.
|
||||
skills_root: 'skills' # Core-owned skill repository shared with Box for optional execution mounts.
|
||||
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -1978,7 +1978,7 @@ class TestBuildSkillExtraMounts:
|
||||
{
|
||||
'host_path': live_dir,
|
||||
'mount_path': '/workspace/.skills/alive',
|
||||
'mode': 'rw',
|
||||
'mode': 'ro',
|
||||
}
|
||||
]
|
||||
# Warning logged so operators can see what was dropped
|
||||
@@ -2003,8 +2003,8 @@ class TestBuildSkillExtraMounts:
|
||||
mounts = service.build_skill_extra_mounts(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'},
|
||||
{'host_path': '/box/skills/a', 'mount_path': '/workspace/.skills/a', 'mode': 'ro'},
|
||||
{'host_path': '/box/skills/b', 'mount_path': '/workspace/.skills/b', 'mode': 'ro'},
|
||||
]
|
||||
# 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)
|
||||
|
||||
@@ -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,9 +609,9 @@ 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):
|
||||
|
||||
@@ -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,175 @@
|
||||
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={
|
||||
'box': {
|
||||
'enabled': False,
|
||||
'local': {
|
||||
'host_root': str(tmp_path / 'box'),
|
||||
'skills_root': 'skills',
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
return SkillRepository(app)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1021,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:
|
||||
@@ -1630,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',
|
||||
|
||||
@@ -1044,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:
|
||||
@@ -1587,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',
|
||||
|
||||
@@ -1026,7 +1026,7 @@ const jaJP = {
|
||||
mcpToolsScopeTooltip:
|
||||
'拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。',
|
||||
skillToolsScopeTooltip:
|
||||
'スキルツールは LangBot のスキルサービスと Box サンドボックスバックエンドが利用可能なときに表示され、Agent がスキルを有効化または登録できるようにします。',
|
||||
'スキルの有効化と読み取り専用リソースには Box は不要です。Agent ワークスペースからの登録には Box サンドボックスが必要です。',
|
||||
selectTools: 'ツールを選択',
|
||||
resourcesTitle: 'リソース',
|
||||
resourcesDescription:
|
||||
@@ -1638,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: 'バックエンド',
|
||||
|
||||
@@ -1034,7 +1034,7 @@ const ruRU = {
|
||||
mcpToolsScopeTooltip:
|
||||
'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.',
|
||||
skillToolsScopeTooltip:
|
||||
'Инструменты навыков доступны, когда сервис навыков LangBot и backend песочницы Box готовы. Они позволяют агенту активировать или регистрировать навыки.',
|
||||
'Активация навыков и ресурсы только для чтения работают без Box. Регистрация из рабочей области Agent по-прежнему требует песочницу Box.',
|
||||
selectTools: 'Выбрать инструменты',
|
||||
resourcesTitle: 'Ресурсы',
|
||||
resourcesDescription:
|
||||
@@ -1560,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: 'Бэкенд',
|
||||
|
||||
@@ -1009,7 +1009,7 @@ const thTH = {
|
||||
mcpToolsScopeTooltip:
|
||||
'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย',
|
||||
skillToolsScopeTooltip:
|
||||
'เครื่องมือสกิลจะแสดงเมื่อบริการสกิลของ LangBot และแบ็กเอนด์แซนด์บ็อกซ์ Box พร้อมใช้งาน เพื่อให้ Agent เปิดใช้หรือลงทะเบียนสกิลได้',
|
||||
'การเปิดใช้สกิลและทรัพยากรแบบอ่านอย่างเดียวทำงานได้โดยไม่ต้องใช้ Box ส่วนการลงทะเบียนจาก workspace ของ Agent ยังต้องใช้ sandbox Box',
|
||||
selectTools: 'เลือกเครื่องมือ',
|
||||
resourcesTitle: 'ทรัพยากร',
|
||||
resourcesDescription:
|
||||
@@ -1529,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: 'แบ็กเอนด์',
|
||||
|
||||
@@ -1026,7 +1026,7 @@ const viVN = {
|
||||
mcpToolsScopeTooltip:
|
||||
'Tại đây chỉ hiển thị công cụ từ máy chủ MCP hiện được cho phép trong Tiện ích mở rộng.',
|
||||
skillToolsScopeTooltip:
|
||||
'Công cụ kỹ năng khả dụng khi dịch vụ kỹ năng LangBot và backend sandbox Box đã sẵn sàng. Chúng cho phép Agent kích hoạt hoặc đăng ký kỹ năng.',
|
||||
'Kích hoạt kỹ năng và tài nguyên chỉ đọc hoạt động không cần Box. Đăng ký từ workspace của Agent vẫn cần sandbox Box.',
|
||||
selectTools: 'Chọn công cụ',
|
||||
resourcesTitle: 'Tài nguyên',
|
||||
resourcesDescription:
|
||||
@@ -1553,9 +1553,9 @@ const viVN = {
|
||||
statusDetail: 'Trạng thái',
|
||||
pluginDisabled: 'Hệ thống plugin đã tắt',
|
||||
boxDisabled:
|
||||
'Sandbox Box đã tắt trong cấu hình — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
|
||||
'Sandbox Box đã tắt trong cấu hình — công cụ thực thi và stdio MCP không khả dụng',
|
||||
boxUnavailable:
|
||||
'Sandbox Box không khả dụng — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
|
||||
'Sandbox Box không khả dụng — công cụ thực thi và stdio MCP không khả dụng',
|
||||
boxRequiredHint:
|
||||
'Tính năng này cần Box runtime. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime đang hoạt động.',
|
||||
boxBackend: 'Backend',
|
||||
|
||||
@@ -978,7 +978,7 @@ const zhHans = {
|
||||
mcpToolsScopeTooltip:
|
||||
'这里仅展示扩展集成当前允许的 MCP 服务器提供的工具。',
|
||||
skillToolsScopeTooltip:
|
||||
'技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。',
|
||||
'技能激活和只读资源无需 Box;Agent 从工作区注册技能仍需要 Box 沙箱。',
|
||||
selectTools: '选择工具',
|
||||
resourcesTitle: '资源',
|
||||
resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。',
|
||||
@@ -1558,10 +1558,8 @@ const zhHans = {
|
||||
disabled: '已禁用',
|
||||
statusDetail: '状态',
|
||||
pluginDisabled: '插件系统已禁用',
|
||||
boxDisabled:
|
||||
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
||||
boxUnavailable:
|
||||
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
||||
boxDisabled: 'Box 沙箱已在配置中禁用——执行工具与 stdio MCP 不可用',
|
||||
boxUnavailable: 'Box 沙箱不可用——执行工具与 stdio MCP 不可用',
|
||||
boxRequiredHint:
|
||||
'此功能依赖 Box 运行时。请在配置中启用(box.enabled = true)并确认运行时连接正常。',
|
||||
boxBackend: '后端',
|
||||
|
||||
@@ -979,7 +979,7 @@ const zhHant = {
|
||||
mcpToolsScopeTooltip:
|
||||
'這裡僅展示擴展集成目前允許的 MCP 伺服器提供的工具。',
|
||||
skillToolsScopeTooltip:
|
||||
'技能工具會在 LangBot 技能服務和 Box 沙箱後端可用時出現,用於讓 Agent 啟用或註冊技能。',
|
||||
'技能啟用和唯讀資源不需要 Box;Agent 從工作區註冊技能仍需要 Box 沙箱。',
|
||||
selectTools: '選擇工具',
|
||||
resourcesTitle: '資源',
|
||||
resourcesDescription: '選擇此內建 Agent 可以讀取的 MCP 資源和知識庫。',
|
||||
@@ -1481,10 +1481,8 @@ const zhHant = {
|
||||
disabled: '已停用',
|
||||
statusDetail: '狀態',
|
||||
pluginDisabled: '外掛系統已停用',
|
||||
boxDisabled:
|
||||
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
||||
boxUnavailable:
|
||||
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
||||
boxDisabled: 'Box 沙箱已在設定中停用——執行工具與 stdio MCP 無法使用',
|
||||
boxUnavailable: 'Box 沙箱無法使用——執行工具與 stdio MCP 無法使用',
|
||||
boxRequiredHint:
|
||||
'此功能需要 Box 執行時。請在設定中啟用(box.enabled = true)並確認執行時連線正常。',
|
||||
boxBackend: '後端',
|
||||
|
||||
Reference in New Issue
Block a user