From c15f668126c09115c3a91757fb36f664e258c26e Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:49:35 +0800 Subject: [PATCH] feat(skill): use SDK store without box execution --- ARCHITECTURE.md | 5 +- docker/docker-compose.yaml | 8 +- docs/review/box-architecture.md | 43 +-- .../pkg/api/http/controller/groups/skills.py | 34 +-- src/langbot/pkg/api/http/service/skill.py | 106 +++---- src/langbot/pkg/box/service.py | 2 +- src/langbot/pkg/core/app.py | 2 + src/langbot/pkg/core/stages/build_app.py | 2 + src/langbot/pkg/pipeline/preproc/preproc.py | 6 +- .../pkg/provider/tools/loaders/native.py | 30 +- .../provider/tools/loaders/skill_authoring.py | 263 ++++++++++++++---- src/langbot/pkg/provider/tools/toolmgr.py | 40 ++- src/langbot/pkg/skill/__init__.py | 4 +- src/langbot/pkg/skill/manager.py | 27 +- src/langbot/pkg/skill/repository.py | 192 +++++++++++++ src/langbot/templates/config.yaml | 10 +- .../api/test_skills_entitlements.py | 16 +- tests/unit_tests/box/test_box_service.py | 6 +- tests/unit_tests/provider/test_skill_tools.py | 210 ++++++++------ .../provider/test_tool_manager_native.py | 23 +- tests/unit_tests/test_preproc.py | 17 ++ tests/unit_tests/test_skill_repository.py | 175 ++++++++++++ tests/unit_tests/test_skill_service.py | 121 +++----- .../pipeline-extensions/PipelineExtension.tsx | 14 +- .../app/home/skills/SkillDetailContent.tsx | 29 +- web/src/app/home/skills/page.tsx | 19 +- web/src/i18n/locales/en-US.ts | 6 +- web/src/i18n/locales/es-ES.ts | 6 +- web/src/i18n/locales/ja-JP.ts | 6 +- web/src/i18n/locales/ru-RU.ts | 6 +- web/src/i18n/locales/th-TH.ts | 6 +- web/src/i18n/locales/vi-VN.ts | 6 +- web/src/i18n/locales/zh-Hans.ts | 8 +- web/src/i18n/locales/zh-Hant.ts | 8 +- 34 files changed, 959 insertions(+), 497 deletions(-) create mode 100644 src/langbot/pkg/skill/repository.py create mode 100644 tests/unit_tests/test_skill_repository.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b5defb82..dd0393b8c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cb5f1c88e..fa40faa40 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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 diff --git a/docs/review/box-architecture.md b/docs/review/box-architecture.md index 2a5e06e65..0f05b2852 100644 --- a/docs/review/box-architecture.md +++ b/docs/review/box-architecture.md @@ -41,7 +41,7 @@ │ │ Action RPC (stdio 或 WebSocket) │ │ │ │ SkillManager (skill_mgr) │ -│ └─ 从 Box runtime 拉取 skills, 不可用时回落 data/skills │ +│ └─ 从 Core SkillRepository 加载 Workspace-scoped skills │ └──────────────────────────────────────────────────────────────────┘ │ ▼ @@ -171,11 +171,10 @@ BoxService ``` SkillManager ├─ initialize() 调用 reload_skills() - ├─ reload_skills() 先从 Box runtime list_skills(), - │ 不可用则回落 data/skills/ 扫描 - ├─ refresh_skill_from_disk() 单 skill 重新加载 + ├─ reload_skills() 从 Core SkillRepository 加载 + ├─ refresh_skill_from_disk() 检查单 skill 的缓存状态 ├─ get_skill_by_name(name) - └─ get_managed_skills_root() 返回 Box 视角的 skills_root 路径 + └─ build_skill_aware_prompt_addition() 生成渐进披露索引 ``` skill 元数据通过 `parse_frontmatter` 解析 `SKILL.md` 头部(`name` / `description` / `instructions`),不再做整体扫描的代价(典型 < 50 个)。 @@ -343,23 +342,24 @@ stdio 模式同样会在 5410 启动 aiohttp,专门承担 managed process atta `BoxSpec` 校验器: `workdir` 默认继承 `mount_path`;`host_path` 支持 POSIX 和 Windows 路径;设置 `host_path` 时 `workdir` 必须在 `mount_path` 下。 -### 3.7 BoxSkillStore (`box/skill_store.py`, 647 行) +### 3.7 SkillStore (`langbot_plugin.skill_store`) -新增模块(commit `4ab3502`),把 skill 持久化收归 Box runtime: +Skill 包存储最初位于 Box Runtime;issue #2410 将通用实现抽到 Plugin SDK 顶层,Core 与 Box 使用同一套存储和 revision 语义: ``` -BoxSkillStore +SkillStore ├─ list_skills() / get_skill(name) ├─ create_skill(data) / update_skill(name, data) / delete_skill(name) ├─ scan_skill_directory(path) 扫描目录返回候选 skill 包列表 - ├─ list_skill_files(name, path) 浏览 skill 内文件树 - ├─ read_skill_file(name, path) / write_skill_file(name, path, content) + ├─ list_skill_resources(name, path, revision) 按 revision 浏览只读资源 + ├─ read_skill_resource(name, path, revision) 按 revision 读取 UTF-8 资源 + ├─ read_skill_file(...) / write_skill_file(...) 管理侧文件接口 ├─ preview_skill_zip(zip_bytes, ...) 不落盘预览 zip 内容 └─ install_skill_zip(zip_bytes, ...) 解压、校验、复制到 skills_root └─ 支持 source_subdir / target_suffix(commit 1aa043f) ``` -GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone` 拉取,再走 `install_skill_zip` 或 directory 路径。Skill 文件存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),容器内对应 `/workspace/.skills/`。 +`langbot_plugin.box.skill_store.BoxSkillStore` 仅保留为旧 Box 配置到通用 `SkillStore` 的兼容适配器,不再拥有存储实现。GitHub 安装路径由 Core HTTP 层下载归档,再交给 SkillRepository。Skill 文件继续存放于 `box.local.skills_root`(默认 `skills`,相对 `host_root`),执行时只读挂载到 `/workspace/.skills/`。 ### 3.8 Security (`box/security.py`, 52 行) @@ -463,7 +463,7 @@ BuildAppStage.run(ap) ├─ ap.tool_mgr = tool_mgr │ ├─ ... (platform, pipeline) ... - ├─ SkillManager.initialize() (从 Box runtime 加载 skill 列表) + ├─ SkillManager.initialize() (从 Core SkillRepository 加载 skill 列表) └─ ... (RAG, HTTP, plugins) ... ``` @@ -480,7 +480,7 @@ except Exception as e: logger.warning(f"Box runtime unavailable: {e}") ``` -**静默降级**: Box 初始化失败不会阻止应用启动,仅导致 6 个 native tool、所有 Skill 工具和 MCP-in-Box 工具不暴露给 LLM。与 Plugin 的行为不同(Plugin 失败会抛异常)。 +**静默降级**: Box 初始化失败不会阻止应用启动。6 个 native tool、`register_skill` 和 MCP-in-Box 工具不暴露给 LLM;`activate` 与 Skill 只读资源工具继续由 Core 提供。与 Plugin 的行为不同(Plugin 失败会抛异常)。 ### 5.3 销毁流程 @@ -508,9 +508,9 @@ box: enabled: true # 整个 Box 子系统的总开关。设为 false 时: # - 不连接远程 Box runtime,不 fork 本地 stdio 子进程 # - sandbox 工具 (exec/read/write/edit/glob/grep) 不暴露给 LLM - # - skill 添加/编辑 / GitHub 安装 / 文件写入全部拒绝 + # - Agent 从 sandbox 注册 skill 的能力不可用 # - stdio 模式的 MCP server 启动时报错(http/sse 模式不受影响) - # - skill 列表/读取保持只读可用 + # - skill 管理、激活和只读资源保持可用 # BOX__ENABLED 环境变量可覆盖(统一约定) backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b' # 由 box.backend / BOX__BACKEND 选择后端 @@ -522,7 +522,7 @@ box: image: '' # 覆盖 profile 默认 image host_root: './data/box' # 工作区挂载根,Docker 部署需绝对路径 default_workspace: '' # 默认 '/default' - skills_root: 'skills' # Box 管理的 skill 包目录(相对 host_root) + skills_root: 'skills' # Core 管理且与 Box 共享的 skill 包目录 allowed_mount_roots: # 默认 [''] - './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) diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 59c091917..995146610 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -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)) diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index 9073ccd4b..fcd4e5313 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -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) diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index bf626090b..72ffa6b2a 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -711,7 +711,7 @@ class BoxService: { 'host_path': package_root, 'mount_path': f'/workspace/.skills/{skill_name}', - 'mode': 'rw', + 'mode': 'ro', } ) return mounts diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index b1951c998..3b2f3a0cd 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -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 ======= diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 7941a5553..32cab789b 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -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 diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index f2d9fe9a1..b7c232cc1 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -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} ' diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index e51195678..3913fbc5e 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -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, diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 5be0dc3e1..bf7ab94d3 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -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'The "{skill_name}" skill is activated\n' result_content += '\n' result_content += f'{skill_name}\n' - result_content += f'{mount_path}\n' - result_content += f'{package_root}\n' + result_content += f'{revision}\n' + result_content += 'true\n' + result_content += f'{str(execution_available).lower()}\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 += '\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, diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 1e4acbbcf..068a357d4 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -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): diff --git a/src/langbot/pkg/skill/__init__.py b/src/langbot/pkg/skill/__init__.py index b96f23ca1..96e673f3a 100644 --- a/src/langbot/pkg/skill/__init__.py +++ b/src/langbot/pkg/skill/__init__.py @@ -1,3 +1,5 @@ from .manager import SkillManager +from .repository import SkillRepository -__all__ = ['SkillManager'] + +__all__ = ['SkillManager', 'SkillRepository'] diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py index aeb688b7f..d0fef97c0 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -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}') diff --git a/src/langbot/pkg/skill/repository.py b/src/langbot/pkg/skill/repository.py new file mode 100644 index 000000000..2d04f7cf3 --- /dev/null +++ b/src/langbot/pkg/skill/repository.py @@ -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'] diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 6b3c716fa..797fadfac 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -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 '/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 [''] when left empty. - './data/box' - '/tmp' diff --git a/tests/integration/api/test_skills_entitlements.py b/tests/integration/api/test_skills_entitlements.py index baafe1f18..a936597ab 100644 --- a/tests/integration/api/test_skills_entitlements.py +++ b/tests/integration/api/test_skills_entitlements.py @@ -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() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index ce6e7bc5f..e9770ba9f 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -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) diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index e96156496..4e0355878 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -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 '' 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): diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index ae50e3935..583692381 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -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) diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index d81f67af5..eedb140c8 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -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 diff --git a/tests/unit_tests/test_skill_repository.py b/tests/unit_tests/test_skill_repository.py new file mode 100644 index 000000000..9fa3a7ded --- /dev/null +++ b/tests/unit_tests/test_skill_repository.py @@ -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)) diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py index 5b2203beb..b551d1566 100644 --- a/tests/unit_tests/test_skill_service.py +++ b/tests/unit_tests/test_skill_service.py @@ -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 diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index a26779109..52dd26082 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -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} /> - {!boxAvailable && ( - - )}
{enableAllSkills ? (
@@ -602,7 +591,6 @@ export default function PipelineExtension({ variant="ghost" size="icon" onClick={() => handleRemoveSkill(skill.name)} - disabled={!boxAvailable} > @@ -616,7 +604,7 @@ export default function PipelineExtension({ onClick={handleOpenSkillDialog} variant="outline" className="w-full" - disabled={enableAllSkills || !boxAvailable} + disabled={enableAllSkills} > {t('pipelines.extensions.addSkill')} diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx index 132b17874..5b45e41f6 100644 --- a/web/src/app/home/skills/SkillDetailContent.tsx +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -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 }) {
{canManage && ( - )} - {!boxAvailable && ( -
- -
- )} -
{t('common.save')}
- {!boxAvailable && ( -
- -
- )} -
{ if (!detailId && !isCreateView) { navigate('/home/add-extension', { replace: true }); @@ -65,20 +57,11 @@ export default function SkillsPage() { -
- {!boxAvailable && ( -
- -
- )}