mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
feat(skill): use SDK store without box execution
This commit is contained in:
@@ -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 .....operation_trace import service as settings_service
|
||||
@@ -25,12 +22,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})
|
||||
|
||||
@@ -48,7 +40,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(
|
||||
@@ -60,7 +52,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')
|
||||
@@ -80,11 +72,11 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError):
|
||||
except ValueError:
|
||||
previous = None
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
changes = settings_service.changed_fields(
|
||||
@@ -101,7 +93,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
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(
|
||||
@@ -123,7 +115,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(
|
||||
@@ -136,7 +128,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(
|
||||
@@ -154,7 +146,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(
|
||||
@@ -191,7 +183,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
|
||||
@@ -215,7 +207,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
|
||||
@@ -243,7 +235,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
|
||||
@@ -266,7 +258,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
|
||||
@@ -285,5 +277,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))
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Optional
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
|
||||
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
@@ -26,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
|
||||
'description',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'revision',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
)
|
||||
@@ -54,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)
|
||||
@@ -114,27 +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 []
|
||||
try:
|
||||
skills = await box_service.list_skills(execution_context)
|
||||
except ActionCallTimeoutError as exc:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Box skill listing timed out: {exc}')
|
||||
return []
|
||||
return [self._serialize_skill(skill) for skill in skills]
|
||||
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]:
|
||||
@@ -142,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
|
||||
|
||||
@@ -170,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()
|
||||
@@ -206,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,
|
||||
@@ -219,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()
|
||||
@@ -236,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',
|
||||
@@ -253,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,
|
||||
@@ -272,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,
|
||||
@@ -284,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,
|
||||
@@ -310,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)
|
||||
@@ -325,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)
|
||||
|
||||
@@ -698,7 +698,7 @@ class BoxService:
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||
'mode': 'rw',
|
||||
'mode': 'ro',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
@@ -47,6 +47,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
|
||||
@@ -91,6 +92,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 =======
|
||||
|
||||
|
||||
@@ -28,6 +28,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 ...operation_trace import service as operation_trace_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
@@ -137,6 +138,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
|
||||
|
||||
|
||||
@@ -33,7 +33,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'}
|
||||
@@ -261,7 +261,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):
|
||||
from ....box.runner import binding_for
|
||||
@@ -1135,7 +1139,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)
|
||||
@@ -1148,7 +1153,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,
|
||||
@@ -1156,7 +1161,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,
|
||||
@@ -1190,12 +1195,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}
|
||||
|
||||
@@ -1228,14 +1234,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,
|
||||
@@ -1250,7 +1252,7 @@ else:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
execution_context = self._execution_context(query)
|
||||
await self.ap.box_service.write_skill_file(
|
||||
await self.ap.skill_repository.write_skill_file(
|
||||
execution_context,
|
||||
selected_skill['name'],
|
||||
relative,
|
||||
|
||||
@@ -6,7 +6,6 @@ import typing
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
from .availability import is_box_backend_available
|
||||
from ....api.http.context import ExecutionContext
|
||||
|
||||
# Align with Claude Code's Skill tool design:
|
||||
@@ -15,12 +14,23 @@ from ....api.http.context import ExecutionContext
|
||||
# - This protects KV Cache and follows industry standard
|
||||
|
||||
ACTIVATE_SKILL_TOOL_NAME = 'activate'
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources'
|
||||
READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource'
|
||||
REGISTER_SKILL_TOOL_NAME = 'register_skill'
|
||||
|
||||
SKILL_TOOL_NAMES = {
|
||||
READ_ONLY_SKILL_TOOL_NAMES = {
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME,
|
||||
READ_SKILL_RESOURCE_TOOL_NAME,
|
||||
}
|
||||
SANDBOX_SKILL_TOOL_NAMES = {
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
}
|
||||
SKILL_TOOL_NAMES = READ_ONLY_SKILL_TOOL_NAMES | SANDBOX_SKILL_TOOL_NAMES
|
||||
_SKILL_EXECUTION_AVAILABLE_KEY = '_skill_execution_available'
|
||||
_SKILL_RESOURCE_BYTES_READ_KEY = '_skill_resource_bytes_read'
|
||||
_MAX_SKILL_RESOURCE_FILE_BYTES = 256 * 1024
|
||||
_MAX_SKILL_RESOURCE_RUN_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class SkillToolLoader(loader.ToolLoader):
|
||||
@@ -28,62 +38,73 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
def __init__(self, ap):
|
||||
super().__init__(ap)
|
||||
self._tools: list[resource_tool.LLMTool] = []
|
||||
self._sandbox_available: bool = False
|
||||
self._read_only_tools: list[resource_tool.LLMTool] = []
|
||||
self._sandbox_tools: list[resource_tool.LLMTool] = []
|
||||
|
||||
async def initialize(self):
|
||||
# Check if sandbox backend is available (same check as native tools)
|
||||
self._sandbox_available = await self._check_sandbox_available()
|
||||
if self._sandbox_available:
|
||||
self._tools = [
|
||||
if self._is_available():
|
||||
self._read_only_tools = [
|
||||
self._build_activate_skill_tool(),
|
||||
self._build_register_skill_tool(),
|
||||
self._build_list_skill_resources_tool(),
|
||||
self._build_read_skill_resource_tool(),
|
||||
]
|
||||
self._sandbox_tools = [self._build_register_skill_tool()]
|
||||
else:
|
||||
self.ap.logger.info(
|
||||
'Skill tools (activate/register_skill) are NOT available. '
|
||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||
'Trusted local development may explicitly select box.backend=host.'
|
||||
)
|
||||
self.ap.logger.info('Skill tools are unavailable because the Core SkillRepository is not initialized.')
|
||||
|
||||
async def _check_sandbox_available(self) -> bool:
|
||||
"""Check if the box backend is truly available (not just the runtime)."""
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not await self._is_available():
|
||||
async def get_tools(
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
*,
|
||||
sandbox_available: bool | None = None,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
if not self._is_available():
|
||||
return []
|
||||
if not self._tools:
|
||||
self._tools = [
|
||||
self._build_activate_skill_tool(),
|
||||
self._build_register_skill_tool(),
|
||||
]
|
||||
return list(self._tools)
|
||||
if not self._read_only_tools:
|
||||
await self.initialize()
|
||||
tools = list(self._read_only_tools)
|
||||
if sandbox_available:
|
||||
tools.extend(self._sandbox_tools)
|
||||
return tools
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return await self._is_available() and name in SKILL_TOOL_NAMES
|
||||
async def get_tool(self, name: str, *, sandbox_available: bool | None = None):
|
||||
for tool in await self.get_tools(sandbox_available=sandbox_available):
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
|
||||
async def _is_available(self) -> bool:
|
||||
"""Check if skill tools should be available.
|
||||
|
||||
Skill tools require both a skill manager and a sandbox backend.
|
||||
"""
|
||||
if not self._has_skill_manager():
|
||||
async def has_tool(self, name: str, *, sandbox_available: bool | None = None) -> bool:
|
||||
if not self._is_available() or name not in SKILL_TOOL_NAMES:
|
||||
return False
|
||||
self._sandbox_available = await self._check_sandbox_available()
|
||||
return self._sandbox_available
|
||||
return name in READ_ONLY_SKILL_TOOL_NAMES or bool(sandbox_available)
|
||||
|
||||
@staticmethod
|
||||
def is_sandbox_tool(name: str) -> bool:
|
||||
return name in SANDBOX_SKILL_TOOL_NAMES
|
||||
|
||||
@staticmethod
|
||||
def recognizes_tool(name: str) -> bool:
|
||||
return name in SKILL_TOOL_NAMES
|
||||
|
||||
def _is_available(self) -> bool:
|
||||
return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if callable(require_sandbox):
|
||||
await require_sandbox(self._execution_context(query))
|
||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_activate_skill(parameters, query)
|
||||
if name == LIST_SKILL_RESOURCES_TOOL_NAME:
|
||||
return await self._invoke_list_skill_resources(parameters, query)
|
||||
if name == READ_SKILL_RESOURCE_TOOL_NAME:
|
||||
return await self._invoke_read_skill_resource(parameters, query)
|
||||
if name == REGISTER_SKILL_TOOL_NAME:
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
None,
|
||||
)
|
||||
if not callable(require_sandbox):
|
||||
return self._sandbox_unavailable_result(name)
|
||||
await require_sandbox(self._execution_context(query))
|
||||
return await self._invoke_register_skill(parameters, query)
|
||||
raise ValueError(f'Unknown skill tool: {name}')
|
||||
|
||||
@@ -108,6 +129,27 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
def _has_skill_manager(self) -> bool:
|
||||
return getattr(self.ap, 'skill_mgr', None) is not None
|
||||
|
||||
@staticmethod
|
||||
def _sandbox_unavailable_result(name: str) -> dict:
|
||||
return {
|
||||
'ok': False,
|
||||
'code': 'sandbox_unavailable',
|
||||
'tool': name,
|
||||
'message': 'This operation requires Box execution, but Box is not configured or available.',
|
||||
}
|
||||
|
||||
async def _execution_available(self, query) -> bool:
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict) and _SKILL_EXECUTION_AVAILABLE_KEY in variables:
|
||||
return bool(variables[_SKILL_EXECUTION_AVAILABLE_KEY])
|
||||
checker = getattr(getattr(self.ap, 'box_service', None), 'is_workspace_sandbox_available', None)
|
||||
if not callable(checker):
|
||||
return False
|
||||
try:
|
||||
return bool(await checker(self._execution_context(query)))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
|
||||
"""Activate a skill and return SKILL.md content via Tool Result."""
|
||||
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
||||
@@ -116,43 +158,112 @@ 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)
|
||||
await skill_loader.persist_activated_skill(self.ap, query, skill_name)
|
||||
|
||||
# Return SKILL.md content as Tool Result (injects into context)
|
||||
instructions = skill_data.get('instructions', '')
|
||||
package_root = skill_data.get('package_root', '')
|
||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name)
|
||||
revision = str(skill_data.get('revision', '') or '')
|
||||
execution_available = await self._execution_available(query)
|
||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name) if execution_available else None
|
||||
|
||||
# Build Tool Result content
|
||||
result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
|
||||
result_content += '<skill-activation>\n'
|
||||
result_content += f'<skill-name>{skill_name}</skill-name>\n'
|
||||
result_content += f'<mount-path>{mount_path}</mount-path>\n'
|
||||
result_content += f'<package-root>{package_root}</package-root>\n'
|
||||
result_content += f'<revision>{revision}</revision>\n'
|
||||
result_content += '<resources-readable>true</resources-readable>\n'
|
||||
result_content += f'<execution-available>{str(execution_available).lower()}</execution-available>\n'
|
||||
result_content += f'\n## Instructions\n{instructions}\n'
|
||||
result_content += '\n## Runtime Context\n'
|
||||
result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n'
|
||||
result_content += f'- Use `read` to inspect files under {mount_path}\n'
|
||||
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n'
|
||||
result_content += '- Use `write` and `edit` on that path when the instructions require updating files\n'
|
||||
result_content += '- Use `list_skill_resources` and `read_skill_resource` for read-only package resources.\n'
|
||||
if execution_available:
|
||||
result_content += (
|
||||
f'- Box execution is available; executable package files will be mounted at {mount_path}.\n'
|
||||
)
|
||||
else:
|
||||
result_content += (
|
||||
'- Box execution is unavailable. Do not attempt to run scripts or modify Workspace files.\n'
|
||||
)
|
||||
result_content += '</skill-activation>\n'
|
||||
|
||||
return {
|
||||
'activated': True,
|
||||
'skill_name': skill_name,
|
||||
'mount_path': mount_path,
|
||||
'revision': revision,
|
||||
'capabilities': {
|
||||
'instructions_readable': True,
|
||||
'resources_readable': True,
|
||||
'execution_available': execution_available,
|
||||
},
|
||||
'activated_skill_names': skill_loader.get_activated_skill_names(query),
|
||||
'content': result_content,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _activated_skill(parameters: dict, query) -> dict:
|
||||
from . import skill as skill_loader
|
||||
|
||||
skill_name = str(parameters.get('skill_name', '') or '').strip()
|
||||
if not skill_name:
|
||||
raise ValueError('skill_name is required')
|
||||
skill_data = skill_loader.get_activated_skill(query, skill_name)
|
||||
if skill_data is None:
|
||||
raise ValueError(f'Skill "{skill_name}" must be activated before its resources can be read.')
|
||||
requested_revision = str(parameters.get('revision', '') or '').strip()
|
||||
activated_revision = str(skill_data.get('revision', '') or '').strip()
|
||||
if requested_revision and requested_revision != activated_revision:
|
||||
raise ValueError('revision must match the activated skill revision')
|
||||
return skill_data
|
||||
|
||||
async def _invoke_list_skill_resources(self, parameters: dict, query) -> dict:
|
||||
skill_data = self._activated_skill(parameters, query)
|
||||
return await self.ap.skill_repository.list_skill_resources(
|
||||
self._execution_context(query),
|
||||
skill_data['name'],
|
||||
str(parameters.get('path', '.') or '.'),
|
||||
expected_revision=skill_data.get('revision'),
|
||||
)
|
||||
|
||||
async def _invoke_read_skill_resource(self, parameters: dict, query) -> dict:
|
||||
skill_data = self._activated_skill(parameters, query)
|
||||
path = str(parameters.get('path', '') or '').strip()
|
||||
if not path:
|
||||
raise ValueError('path is required')
|
||||
result = await self.ap.skill_repository.read_skill_resource(
|
||||
self._execution_context(query),
|
||||
skill_data['name'],
|
||||
path,
|
||||
expected_revision=skill_data.get('revision'),
|
||||
)
|
||||
content = str(result.get('content', ''))
|
||||
size = len(content.encode('utf-8'))
|
||||
if size > _MAX_SKILL_RESOURCE_FILE_BYTES:
|
||||
raise ValueError('Skill resource exceeds the per-file read limit')
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
total = int(variables.get(_SKILL_RESOURCE_BYTES_READ_KEY, 0) or 0) + size
|
||||
if total > _MAX_SKILL_RESOURCE_RUN_BYTES:
|
||||
raise ValueError('Skill resource reads exceed the per-run limit')
|
||||
variables[_SKILL_RESOURCE_BYTES_READ_KEY] = total
|
||||
result['size'] = size
|
||||
return result
|
||||
|
||||
async def _invoke_register_skill(self, parameters: dict, query) -> typing.Any:
|
||||
"""Register a skill from sandbox directory to data/skills/."""
|
||||
from . import skill as skill_loader
|
||||
@@ -179,14 +290,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,
|
||||
},
|
||||
)
|
||||
skill_loader.register_created_skill_visibility(query, skill_name)
|
||||
@@ -253,6 +364,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,
|
||||
|
||||
@@ -78,6 +78,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
|
||||
@@ -114,8 +123,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(
|
||||
@@ -154,8 +163,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:
|
||||
@@ -297,10 +310,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)
|
||||
|
||||
@@ -576,7 +588,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',
|
||||
@@ -585,6 +600,13 @@ class ToolManager:
|
||||
query=query,
|
||||
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
|
||||
)
|
||||
recognizes_native = getattr(self.native_tool_loader, 'recognizes_tool', None)
|
||||
is_sandbox_skill_tool = getattr(self.skill_tool_loader, 'is_sandbox_tool', None)
|
||||
if not sandbox_available and (
|
||||
(callable(recognizes_native) and recognizes_native(name) is True)
|
||||
or (callable(is_sandbox_skill_tool) and is_sandbox_skill_tool(name) is True)
|
||||
):
|
||||
return self._sandbox_unavailable_result(name)
|
||||
raise ToolNotFoundError(name)
|
||||
|
||||
async def shutdown(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from .manager import SkillManager
|
||||
from .repository import SkillRepository
|
||||
|
||||
__all__ = ['SkillManager']
|
||||
|
||||
__all__ = ['SkillManager', 'SkillRepository']
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ..core import app
|
||||
|
||||
|
||||
class SkillManager:
|
||||
"""Workspace-scoped in-memory view of Box-managed skill packages."""
|
||||
"""Workspace-scoped in-memory view of Core-managed skill packages."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
@@ -63,33 +61,20 @@ class SkillManager:
|
||||
self._skills_by_scope.pop(existing_key, None)
|
||||
self._skills_by_scope[key] = {}
|
||||
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
self.ap.logger.info(
|
||||
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
|
||||
)
|
||||
repository = getattr(self.ap, 'skill_repository', None)
|
||||
if repository is None:
|
||||
self.ap.logger.info('Skill repository unavailable; skill cache will remain empty.')
|
||||
return
|
||||
|
||||
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
|
||||
try:
|
||||
dropped = 0
|
||||
skills: dict[str, dict] = {}
|
||||
for skill_data in await box_service.list_skills(execution_context):
|
||||
for skill_data in await repository.list_skills(execution_context):
|
||||
skill_name = skill_data.get('name')
|
||||
if not skill_name:
|
||||
continue
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if validate_locally and package_root and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" reported by Box runtime but package_root '
|
||||
f'missing on LangBot filesystem ({package_root}); dropping from cache.'
|
||||
)
|
||||
dropped += 1
|
||||
continue
|
||||
skills[skill_name] = skill_data
|
||||
self._skills_by_scope[key] = skills
|
||||
suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
|
||||
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
|
||||
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}')
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from langbot_plugin.skill_store import (
|
||||
SkillRevisionMismatchError,
|
||||
SkillStore,
|
||||
skill_namespace,
|
||||
)
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
|
||||
|
||||
|
||||
class SkillRepository:
|
||||
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
|
||||
|
||||
def __init__(self, ap) -> None:
|
||||
self.ap = ap
|
||||
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
|
||||
self._local_config = (config.get('box') or {}).get('local') or {}
|
||||
self._store = SkillStore(self._skills_root())
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _host_root(self) -> str:
|
||||
configured = str(self._local_config.get('host_root') or './data/box').strip()
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
def _skills_root(self) -> str:
|
||||
configured = str(self._local_config.get('skills_root') or 'skills').strip()
|
||||
if not os.path.isabs(configured):
|
||||
configured = os.path.join(self._host_root(), configured)
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
def _default_workspace(self) -> str:
|
||||
configured = str(self._local_config.get('default_workspace') or '').strip()
|
||||
if not configured:
|
||||
configured = os.path.join(self._host_root(), 'default')
|
||||
elif not os.path.isabs(configured):
|
||||
configured = os.path.join(self._host_root(), configured)
|
||||
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
|
||||
|
||||
@staticmethod
|
||||
def _execution_context(context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
|
||||
generation = getattr(context, 'placement_generation', None)
|
||||
if not instance_uuid:
|
||||
raise ValueError('Skill operations require an explicit instance UUID')
|
||||
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
|
||||
raise ValueError('Skill operations require a positive placement generation')
|
||||
return ExecutionContext(
|
||||
instance_uuid=instance_uuid,
|
||||
workspace_uuid=workspace_uuid,
|
||||
placement_generation=generation,
|
||||
bot_uuid=getattr(context, 'bot_uuid', None),
|
||||
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
|
||||
query_uuid=getattr(context, 'query_uuid', None),
|
||||
entitlement_revision=getattr(context, 'entitlement_revision', 0),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _namespace(cls, context: TenantContext) -> str:
|
||||
execution_context = cls._execution_context(context)
|
||||
return skill_namespace(
|
||||
execution_context.instance_uuid,
|
||||
execution_context.workspace_uuid,
|
||||
)
|
||||
|
||||
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = self._execution_context(context)
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
execution_context.workspace_uuid,
|
||||
expected_generation=execution_context.placement_generation,
|
||||
)
|
||||
if (
|
||||
binding.instance_uuid != execution_context.instance_uuid
|
||||
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
|
||||
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
|
||||
):
|
||||
raise ValueError('Skill execution context belongs to a stale Workspace placement')
|
||||
return execution_context
|
||||
|
||||
def _workspace_root(self, namespace: str) -> str:
|
||||
return os.path.join(self._default_workspace(), 'tenants', namespace)
|
||||
|
||||
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
namespace = self._namespace(execution_context)
|
||||
|
||||
def invoke():
|
||||
method = getattr(self._store.scoped(namespace), method_name)
|
||||
return method(*args, **kwargs)
|
||||
|
||||
async with self._lock:
|
||||
with blocking_work_scope(f'skill:{namespace}'):
|
||||
return await run_blocking_atomic(invoke)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
return await self._call(context, 'list_skills')
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str, *, snapshot: bool = False) -> dict | None:
|
||||
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
return await self._call(context, 'create_skill', skill)
|
||||
|
||||
async def import_skill_directory(self, context: TenantContext, path: str, skill: dict) -> dict:
|
||||
namespace = self._namespace(context)
|
||||
return await self._call(
|
||||
context,
|
||||
'import_skill_directory',
|
||||
path,
|
||||
skill,
|
||||
source_root=self._workspace_root(namespace),
|
||||
)
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
return await self._call(context, 'update_skill', name, skill)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
await self._call(context, 'delete_skill', name)
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
namespace = self._namespace(context)
|
||||
return await self._call(
|
||||
context,
|
||||
'scan_import_directory',
|
||||
path,
|
||||
source_root=self._workspace_root(namespace),
|
||||
)
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
return await self._call(context, 'read_skill_file', name, path)
|
||||
|
||||
async def list_skill_resources(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
*,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict:
|
||||
return await self._call(
|
||||
context,
|
||||
'list_skill_resources',
|
||||
name,
|
||||
path,
|
||||
False,
|
||||
200,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
async def read_skill_resource(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str,
|
||||
*,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict:
|
||||
return await self._call(
|
||||
context,
|
||||
'read_skill_resource',
|
||||
name,
|
||||
path,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
return await self._call(context, 'write_skill_file', name, path, content)
|
||||
|
||||
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
||||
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
||||
|
||||
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
|
||||
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
|
||||
|
||||
|
||||
__all__ = ['SkillRepository', 'SkillRevisionMismatchError']
|
||||
@@ -346,10 +346,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
|
||||
@@ -398,7 +398,7 @@ box:
|
||||
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
||||
host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path.
|
||||
default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root.
|
||||
skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root.
|
||||
skills_root: 'skills' # Core-owned skill repository shared with Box for optional execution mounts.
|
||||
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
|
||||
- './data/box'
|
||||
- '/tmp'
|
||||
|
||||
Reference in New Issue
Block a user