feat(skill): use SDK store without box execution

This commit is contained in:
huanghuoguoguo
2026-09-28 10:18:16 +08:00
parent a75e57ade3
commit 7bf20490f4
32 changed files with 940 additions and 527 deletions
@@ -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))
+37 -77
View File
@@ -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)
+1 -1
View File
@@ -698,7 +698,7 @@ class BoxService:
{
'host_path': package_root,
'mount_path': f'/workspace/.skills/{skill_name}',
'mode': 'rw',
'mode': 'ro',
}
)
return mounts
+2
View File
@@ -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 =======
+2
View File
@@ -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,
+31 -9
View File
@@ -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):
+3 -1
View File
@@ -1,3 +1,5 @@
from .manager import SkillManager
from .repository import SkillRepository
__all__ = ['SkillManager']
__all__ = ['SkillManager', 'SkillRepository']
+6 -21
View File
@@ -1,14 +1,12 @@
from __future__ import annotations
import os
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..core import app
class SkillManager:
"""Workspace-scoped in-memory view of Box-managed skill packages."""
"""Workspace-scoped in-memory view of Core-managed skill packages."""
ap: app.Application
@@ -63,33 +61,20 @@ class SkillManager:
self._skills_by_scope.pop(existing_key, None)
self._skills_by_scope[key] = {}
box_service = getattr(self.ap, 'box_service', None)
if box_service is None or not getattr(box_service, 'available', False):
self.ap.logger.info(
f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
)
repository = getattr(self.ap, 'skill_repository', None)
if repository is None:
self.ap.logger.info('Skill repository unavailable; skill cache will remain empty.')
return
validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
try:
dropped = 0
skills: dict[str, dict] = {}
for skill_data in await box_service.list_skills(execution_context):
for skill_data in await repository.list_skills(execution_context):
skill_name = skill_data.get('name')
if not skill_name:
continue
package_root = str(skill_data.get('package_root', '') or '').strip()
if validate_locally and package_root and not os.path.isdir(package_root):
self.ap.logger.warning(
f'Skill "{skill_name}" reported by Box runtime but package_root '
f'missing on LangBot filesystem ({package_root}); dropping from cache.'
)
dropped += 1
continue
skills[skill_name] = skill_data
self._skills_by_scope[key] = skills
suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}')
except Exception as exc:
self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import asyncio
import os
from langbot_plugin.skill_store import (
SkillRevisionMismatchError,
SkillStore,
skill_namespace,
)
from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..utils.bounded_executor import blocking_work_scope, run_blocking_atomic
class SkillRepository:
"""Async, Workspace-scoped adapter around the SDK SkillStore."""
def __init__(self, ap) -> None:
self.ap = ap
config = getattr(getattr(ap, 'instance_config', None), 'data', {}) or {}
self._local_config = (config.get('box') or {}).get('local') or {}
self._store = SkillStore(self._skills_root())
self._lock = asyncio.Lock()
def _host_root(self) -> str:
configured = str(self._local_config.get('host_root') or './data/box').strip()
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _skills_root(self) -> str:
configured = str(self._local_config.get('skills_root') or 'skills').strip()
if not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
def _default_workspace(self) -> str:
configured = str(self._local_config.get('default_workspace') or '').strip()
if not configured:
configured = os.path.join(self._host_root(), 'default')
elif not os.path.isabs(configured):
configured = os.path.join(self._host_root(), configured)
return os.path.realpath(os.path.abspath(os.path.expanduser(configured)))
@staticmethod
def _execution_context(context: TenantContext) -> ExecutionContext:
workspace_uuid = require_workspace_uuid(context)
instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
generation = getattr(context, 'placement_generation', None)
if not instance_uuid:
raise ValueError('Skill operations require an explicit instance UUID')
if isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
raise ValueError('Skill operations require a positive placement generation')
return ExecutionContext(
instance_uuid=instance_uuid,
workspace_uuid=workspace_uuid,
placement_generation=generation,
bot_uuid=getattr(context, 'bot_uuid', None),
pipeline_uuid=getattr(context, 'pipeline_uuid', None),
query_uuid=getattr(context, 'query_uuid', None),
entitlement_revision=getattr(context, 'entitlement_revision', 0),
)
@classmethod
def _namespace(cls, context: TenantContext) -> str:
execution_context = cls._execution_context(context)
return skill_namespace(
execution_context.instance_uuid,
execution_context.workspace_uuid,
)
async def _validated_execution_context(self, context: TenantContext) -> ExecutionContext:
execution_context = self._execution_context(context)
binding = await self.ap.workspace_service.get_execution_binding(
execution_context.workspace_uuid,
expected_generation=execution_context.placement_generation,
)
if (
binding.instance_uuid != execution_context.instance_uuid
or str(getattr(binding, 'workspace_uuid', '') or '') != execution_context.workspace_uuid
or getattr(binding, 'placement_generation', None) != execution_context.placement_generation
):
raise ValueError('Skill execution context belongs to a stale Workspace placement')
return execution_context
def _workspace_root(self, namespace: str) -> str:
return os.path.join(self._default_workspace(), 'tenants', namespace)
async def _call(self, context: TenantContext, method_name: str, *args, **kwargs):
execution_context = await self._validated_execution_context(context)
namespace = self._namespace(execution_context)
def invoke():
method = getattr(self._store.scoped(namespace), method_name)
return method(*args, **kwargs)
async with self._lock:
with blocking_work_scope(f'skill:{namespace}'):
return await run_blocking_atomic(invoke)
async def list_skills(self, context: TenantContext) -> list[dict]:
return await self._call(context, 'list_skills')
async def get_skill(self, context: TenantContext, name: str, *, snapshot: bool = False) -> dict | None:
return await self._call(context, 'get_skill_snapshot' if snapshot else 'get_skill', name)
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
return await self._call(context, 'create_skill', skill)
async def import_skill_directory(self, context: TenantContext, path: str, skill: dict) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'import_skill_directory',
path,
skill,
source_root=self._workspace_root(namespace),
)
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
return await self._call(context, 'update_skill', name, skill)
async def delete_skill(self, context: TenantContext, name: str) -> None:
await self._call(context, 'delete_skill', name)
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
namespace = self._namespace(context)
return await self._call(
context,
'scan_import_directory',
path,
source_root=self._workspace_root(namespace),
)
async def list_skill_files(
self,
context: TenantContext,
name: str,
path: str = '.',
include_hidden: bool = False,
max_entries: int = 200,
) -> dict:
return await self._call(context, 'list_skill_files', name, path, include_hidden, max_entries)
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
return await self._call(context, 'read_skill_file', name, path)
async def list_skill_resources(
self,
context: TenantContext,
name: str,
path: str = '.',
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'list_skill_resources',
name,
path,
False,
200,
expected_revision=expected_revision,
)
async def read_skill_resource(
self,
context: TenantContext,
name: str,
path: str,
*,
expected_revision: str | None = None,
) -> dict:
return await self._call(
context,
'read_skill_resource',
name,
path,
expected_revision=expected_revision,
)
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
return await self._call(context, 'write_skill_file', name, path, content)
async def preview_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'preview_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
async def install_skill_zip(self, context: TenantContext, file_bytes: bytes, filename: str, **kwargs) -> list[dict]:
return await self._call(context, 'install_zip_upload', file_bytes=file_bytes, filename=filename, **kwargs)
__all__ = ['SkillRepository', 'SkillRevisionMismatchError']
+5 -5
View File
@@ -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'