feat(skill): use SDK store without box execution

This commit is contained in:
huanghuoguoguo
2026-09-05 19:49:35 +08:00
parent ec63978ecf
commit c15f668126
34 changed files with 959 additions and 497 deletions
+3 -2
View File
@@ -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
+6 -2
View File
@@ -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
+22 -21
View File
@@ -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 Runtimeissue #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_suffixcommit 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))
+37 -69
View File
@@ -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)
+1 -1
View File
@@ -711,7 +711,7 @@ class BoxService:
{
'host_path': package_root,
'mount_path': f'/workspace/.skills/{skill_name}',
'mode': 'rw',
'mode': 'ro',
}
)
return mounts
+2
View File
@@ -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 =======
+2
View File
@@ -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
+4 -2
View File
@@ -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,
+31 -9
View File
@@ -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):
+3 -1
View File
@@ -1,3 +1,5 @@
from .manager import SkillManager
from .repository import SkillRepository
__all__ = ['SkillManager']
__all__ = ['SkillManager', 'SkillRepository']
+6 -21
View File
@@ -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}')
+192
View File
@@ -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']
+5 -5
View File
@@ -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()
+3 -3
View File
@@ -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)
+120 -90
View File
@@ -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)
+17
View File
@@ -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
+175
View File
@@ -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))
+46 -75
View File
@@ -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')}
+2 -27
View File
@@ -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
+1 -18
View File
@@ -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
+3 -3
View File
@@ -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',
+3 -3
View File
@@ -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',
+3 -3
View File
@@ -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: 'バックエンド',
+3 -3
View File
@@ -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: 'Бэкенд',
+3 -3
View File
@@ -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: 'แบ็กเอนด์',
+3 -3
View File
@@ -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',
+3 -5
View File
@@ -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: '后端',
+3 -5
View File
@@ -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: '後端',