diff --git a/pyproject.toml b/pyproject.toml index 2b384547..cb8ac1fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,6 +127,9 @@ dev = [ "ruff>=0.11.9", ] +[tool.uv.sources] +langbot-plugin = { path = "../langbot-plugin-sdk", editable = true } + [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ @@ -220,4 +223,3 @@ skip-magic-trailing-comma = false # Like Black, automatically detect the appropriate line ending. line-ending = "auto" - diff --git a/src/langbot/__main__.py b/src/langbot/__main__.py index b94500e7..bd2af8ff 100644 --- a/src/langbot/__main__.py +++ b/src/langbot/__main__.py @@ -5,6 +5,8 @@ import argparse import sys import os +from langbot.pkg.utils import paths + # ASCII art banner asciiart = r""" _ ___ _ @@ -87,7 +89,7 @@ def main(): # Set up the working directory # When installed as a package, we need to handle the working directory differently # We'll create data directory in current working directory if not exists - os.makedirs('data', exist_ok=True) + os.makedirs(paths.get_data_root(), exist_ok=True) loop = asyncio.new_event_loop() diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index e7fb6118..c6b2a1b4 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -73,15 +73,21 @@ class PipelinesRouterGroup(group.RouterGroup): plugins = await self.ap.plugin_connector.list_plugins(component_kinds=pipeline_component_kinds) mcp_servers = await self.ap.mcp_service.get_mcp_servers(contain_runtime_info=True) + # Get available skills + available_skills = await self.ap.skill_service.list_skills() + extensions_prefs = pipeline.get('extensions_preferences', {}) return self.success( data={ 'enable_all_plugins': extensions_prefs.get('enable_all_plugins', True), 'enable_all_mcp_servers': extensions_prefs.get('enable_all_mcp_servers', True), + 'enable_all_skills': extensions_prefs.get('enable_all_skills', True), 'bound_plugins': extensions_prefs.get('plugins', []), 'available_plugins': plugins, 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), 'available_mcp_servers': mcp_servers, + 'bound_skills': extensions_prefs.get('skills', []), + 'available_skills': available_skills, } ) elif quart.request.method == 'PUT': @@ -89,11 +95,19 @@ class PipelinesRouterGroup(group.RouterGroup): json_data = await quart.request.json enable_all_plugins = json_data.get('enable_all_plugins', True) enable_all_mcp_servers = json_data.get('enable_all_mcp_servers', True) + enable_all_skills = json_data.get('enable_all_skills', True) bound_plugins = json_data.get('bound_plugins', []) bound_mcp_servers = json_data.get('bound_mcp_servers', []) + bound_skills = json_data.get('bound_skills', []) await self.ap.pipeline_service.update_pipeline_extensions( - pipeline_uuid, bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers + pipeline_uuid, + bound_plugins, + bound_mcp_servers, + enable_all_plugins, + enable_all_mcp_servers, + bound_skills=bound_skills, + enable_all_skills=enable_all_skills, ) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index c4d28bb4..73afbdec 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -6,6 +6,7 @@ import re import httpx import uuid import os +from urllib.parse import urlparse from .....core import taskmgr from .. import group @@ -14,6 +15,43 @@ from langbot_plugin.runtime.plugin.mgr import PluginInstallSource @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): + @staticmethod + def _parse_github_repo_url(repo_url: str) -> dict | None: + raw_url = str(repo_url or '').strip() + if not raw_url: + return None + + if not re.match(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', raw_url): + raw_url = f'https://{raw_url}' + + parsed = urlparse(raw_url) + if parsed.netloc.lower() not in ('github.com', 'www.github.com'): + return None + + parts = [part for part in parsed.path.strip('/').split('/') if part] + if len(parts) < 2: + return None + + owner = parts[0] + repo = parts[1] + if repo.endswith('.git'): + repo = repo[:-4] + if not owner or not repo: + return None + + ref = '' + subdir = '' + if len(parts) >= 4 and parts[2] in ('tree', 'blob'): + ref = parts[3] + subdir = '/'.join(parts[4:]).strip('/') + + return { + 'owner': owner, + 'repo': repo, + 'ref': ref, + 'subdir': subdir, + } + async def _check_extensions_limit(self) -> str | None: """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) @@ -151,17 +189,37 @@ class PluginsRouterGroup(group.RouterGroup): data = await quart.request.json repo_url = data.get('repo_url', '') - # Parse GitHub repository URL to extract owner and repo - # Supports: https://github.com/owner/repo or github.com/owner/repo - pattern = r'github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$' - match = re.search(pattern, repo_url) - - if not match: + parsed_repo = self._parse_github_repo_url(repo_url) + if not parsed_repo: return self.http_status(400, -1, 'Invalid GitHub repository URL') - owner, repo = match.groups() + owner = parsed_repo['owner'] + repo = parsed_repo['repo'] + requested_ref = parsed_repo['ref'] + requested_subdir = parsed_repo['subdir'] try: + if requested_ref: + return self.success( + data={ + 'releases': [ + { + 'id': 0, + 'tag_name': requested_ref, + 'name': requested_ref, + 'published_at': '', + 'prerelease': False, + 'draft': False, + 'source_type': 'branch', + 'archive_url': f'https://api.github.com/repos/{owner}/{repo}/zipball/{requested_ref}', + } + ], + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) + # Fetch releases from GitHub API url = f'https://api.github.com/repos/{owner}/{repo}/releases' async with httpx.AsyncClient( @@ -187,7 +245,14 @@ class PluginsRouterGroup(group.RouterGroup): } ) - return self.success(data={'releases': formatted_releases, 'owner': owner, 'repo': repo}) + return self.success( + data={ + 'releases': formatted_releases, + 'owner': owner, + 'repo': repo, + 'source_subdir': requested_subdir, + } + ) except httpx.RequestError as e: return self.http_status(500, -1, f'Failed to fetch releases: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py new file mode 100644 index 00000000..73350246 --- /dev/null +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import quart + +from .. import group + + +@group.group_class('skills', '/api/v1/skills') +class SkillsRouterGroup(group.RouterGroup): + """Skills management API endpoints.""" + + async def initialize(self) -> None: + @self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def list_or_create_skills() -> quart.Response: + if quart.request.method == 'GET': + skills = await self.ap.skill_service.list_skills() + return self.success(data={'skills': skills}) + + data = await quart.request.json + if 'name' not in data or not data['name']: + return self.http_status(400, -1, 'Missing required field: name') + + try: + skill = await self.ap.skill_service.create_skill(data) + return self.success(data={'skill': skill}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def get_update_delete_skill(skill_name: str) -> quart.Response: + if quart.request.method == 'GET': + skill = await self.ap.skill_service.get_skill(skill_name) + if not skill: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'skill': skill}) + + if quart.request.method == 'PUT': + data = await quart.request.json + try: + skill = await self.ap.skill_service.update_skill(skill_name, data) + return self.success(data={'skill': skill}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + try: + await self.ap.skill_service.delete_skill(skill_name) + return self.success() + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + + @self.route('//preview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill(skill_name: str) -> quart.Response: + runtime_data = self.ap.skill_mgr.get_skill_runtime_data(skill_name) + if not runtime_data: + return self.http_status(404, -1, 'Skill not found') + return self.success(data={'instructions': runtime_data['instructions']}) + + @self.route('/index', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def get_skill_index() -> quart.Response: + pipeline_uuid = quart.request.args.get('pipeline_uuid') + bound_skills = quart.request.args.getlist('bound_skills') + skill_index = self.ap.skill_mgr.get_skill_index( + pipeline_uuid=pipeline_uuid, + bound_skills=bound_skills if bound_skills else None, + ) + return self.success(data={'index': skill_index}) + + @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo', 'release_tag'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + + try: + skill = await self.ap.skill_service.install_from_github(data) + return self.success(data={'skills': skill}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/github/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_github() -> quart.Response: + data = await quart.request.json + required_fields = ['asset_url', 'owner', 'repo', 'release_tag'] + for field in required_fields: + if field not in data or not data[field]: + return self.http_status(400, -1, f'Missing required field: {field}') + + try: + preview = await self.ap.skill_service.preview_install_from_github(data) + return self.success(data={'skills': preview}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/install/upload', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def install_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + form = await quart.request.form + + try: + skill = await self.ap.skill_service.install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + source_paths=form.getlist('source_paths'), + ) + return self.success(data={'skills': skill}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to install skill: {exc}') + + @self.route('/install/upload/preview', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def preview_skill_from_upload() -> quart.Response: + file = (await quart.request.files).get('file') + if file is None: + return self.http_status(400, -1, 'file is required') + + try: + preview = await self.ap.skill_service.preview_install_from_zip_upload( + file_bytes=file.read(), + filename=file.filename or '', + ) + return self.success(data={'skills': preview}) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) + except Exception as exc: + return self.http_status(500, -1, f'Failed to preview skill: {exc}') + + @self.route('/scan', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def scan_skill_directory() -> quart.Response: + path = quart.request.args.get('path', '').strip() + if not path: + return self.http_status(400, -1, 'Missing required parameter: path') + + try: + result = self.ap.skill_service.scan_directory(path) + return self.success(data=result) + except ValueError as exc: + return self.http_status(400, -1, str(exc)) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index ad75ffe7..28c6b94c 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -220,6 +220,8 @@ class PipelineService: bound_mcp_servers: list[str] = None, enable_all_plugins: bool = True, enable_all_mcp_servers: bool = True, + bound_skills: list[str] = None, + enable_all_skills: bool = True, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline @@ -237,9 +239,12 @@ class PipelineService: extensions_preferences = pipeline.extensions_preferences or {} extensions_preferences['enable_all_plugins'] = enable_all_plugins extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers + extensions_preferences['enable_all_skills'] = enable_all_skills extensions_preferences['plugins'] = bound_plugins if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers + if bound_skills is not None: + extensions_preferences['skills'] = bound_skills await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py new file mode 100644 index 00000000..09e640d7 --- /dev/null +++ b/src/langbot/pkg/api/http/service/skill.py @@ -0,0 +1,743 @@ +from __future__ import annotations + +import io +import inspect +import os +import posixpath +import shutil +import tempfile +import zipfile +from typing import Optional +from urllib.parse import urlparse + +import httpx +import yaml + +from ....core import app +from ....skill.utils import parse_frontmatter +from ....utils import paths + +_FRONTMATTER_FIELDS = ( + 'name', + 'display_name', + 'description', + 'auto_activate', +) + +_PUBLIC_SKILL_FIELDS = ( + 'name', + 'display_name', + 'description', + 'instructions', + 'package_root', + 'auto_activate', + 'created_at', + 'updated_at', +) + +_GITHUB_ASSET_HOSTS = { + 'github.com', + 'api.github.com', + 'objects.githubusercontent.com', + 'githubusercontent.com', + 'raw.githubusercontent.com', + 'codeload.github.com', +} + + +def _build_skill_md(metadata: dict, instructions: str) -> str: + frontmatter = {} + for key in _FRONTMATTER_FIELDS: + value = metadata.get(key) + if value is None: + continue + if key == 'auto_activate' and value is True: + continue + if isinstance(value, str) and not value.strip(): + continue + frontmatter[key] = value + + if not frontmatter: + return instructions + + frontmatter_text = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False).strip() + return f'---\n{frontmatter_text}\n---\n\n{instructions}' + + +class SkillService: + """Filesystem-backed skill management service.""" + + ap: app.Application + + def __init__(self, ap: app.Application) -> None: + self.ap = ap + + @staticmethod + def _serialize_skill(skill: dict) -> dict: + return {field: skill.get(field) for field in _PUBLIC_SKILL_FIELDS if field in skill} + + async def list_skills(self) -> list[dict]: + skills = [dict(skill) for skill in getattr(self.ap.skill_mgr, 'skills', {}).values()] + skills.sort(key=lambda item: item.get('updated_at', ''), reverse=True) + return [self._serialize_skill(skill) for skill in skills] + + async def get_skill(self, skill_name: str) -> Optional[dict]: + skill = getattr(self.ap.skill_mgr, 'get_skill_by_name', lambda _name: None)(skill_name) + return self._serialize_skill(skill) if skill else None + + async def get_skill_by_name(self, name: str) -> Optional[dict]: + return await self.get_skill(name) + + async def create_skill(self, data: dict) -> dict: + name = self._validate_skill_name(data.get('name', '')) + if await self.get_skill_by_name(name): + raise ValueError(f'Skill with name "{name}" already exists') + + package_root = self._normalize_package_root(data.get('package_root', '')) + managed_root = self._managed_skill_path(name) + target_root = managed_root + imported_skill_data: dict | None = None + + if package_root and self._managed_install_root_for_package(package_root): + if not os.path.isdir(package_root): + raise ValueError(f'Directory does not exist: {package_root}') + target_root = package_root + imported_skill_data = self._read_skill_package(target_root) + elif package_root and package_root != managed_root: + if not os.path.isdir(package_root): + raise ValueError(f'Directory does not exist: {package_root}') + if os.path.exists(managed_root): + raise ValueError(f'Skill directory already exists: {managed_root}') + os.makedirs(os.path.dirname(managed_root), exist_ok=True) + shutil.copytree(package_root, managed_root) + imported_skill_data = self._read_skill_package(managed_root) + else: + os.makedirs(managed_root, exist_ok=True) + + metadata = { + 'name': name, + 'display_name': self._resolve_create_field(data, 'display_name', imported_skill_data, default=''), + 'description': self._resolve_create_field(data, 'description', imported_skill_data, default=''), + 'auto_activate': self._resolve_create_bool(data, 'auto_activate', imported_skill_data, default=True), + } + instructions = self._resolve_create_field(data, 'instructions', imported_skill_data, default='') + self._write_skill_md(target_root, metadata, instructions) + + await self._reload_skills() + created = await self.get_skill(name) + if not created: + raise ValueError(f'Failed to create skill "{name}"') + return created + + async def update_skill(self, skill_name: str, data: dict) -> dict: + skill = await self.get_skill(skill_name) + if not skill: + raise ValueError(f'Skill "{skill_name}" not found') + + requested_name = str(data.get('name', skill['name']) or skill['name']).strip() + if requested_name != skill['name']: + raise ValueError('Renaming skills is not supported') + + requested_package_root = str(data.get('package_root', '') or '').strip() + existing_package_root = self._normalize_package_root(skill['package_root']) + if requested_package_root and self._normalize_package_root(requested_package_root) != existing_package_root: + raise ValueError('Updating package_root is not supported; recreate the skill to import a different package') + + metadata = { + 'name': skill['name'], + 'display_name': data.get('display_name', skill.get('display_name', '')), + 'description': data.get('description', skill.get('description', '')), + 'auto_activate': data.get('auto_activate', skill.get('auto_activate', True)), + } + instructions = str(data.get('instructions', skill.get('instructions', '')) or '') + self._write_skill_md(skill['package_root'], metadata, instructions) + + await self._reload_skills() + updated = await self.get_skill(skill_name) + if not updated: + raise ValueError(f'Skill "{skill_name}" not found after update') + return updated + + async def delete_skill(self, skill_name: str) -> bool: + skill = await self.get_skill(skill_name) + if not skill: + raise ValueError(f'Skill "{skill_name}" not found') + + package_root = self._normalize_package_root(skill['package_root']) + managed_install_root = self._managed_install_root_for_package(package_root) + if not managed_install_root: + raise ValueError('Only managed skills under data/skills can be deleted via LangBot') + + shutil.rmtree(managed_install_root, ignore_errors=True) + await self._reload_skills() + return True + + async def list_skill_files( + self, + skill_name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + skill = await self.get_skill(skill_name) + if not skill: + raise ValueError(f'Skill "{skill_name}" not found') + + target_dir, relative_path = self._resolve_skill_path(skill, path, expect_directory=True) + entries: list[dict] = [] + with os.scandir(target_dir) as iterator: + for entry in sorted(iterator, key=lambda item: item.name): + if not include_hidden and entry.name.startswith('.'): + continue + entry_rel_path = entry.name if relative_path in ('', '.') else os.path.join(relative_path, entry.name) + is_dir = entry.is_dir() + entries.append( + { + 'path': entry_rel_path.replace(os.sep, '/'), + 'name': entry.name, + 'is_dir': is_dir, + 'size': None if is_dir else entry.stat().st_size, + } + ) + if len(entries) >= max_entries: + break + + return { + 'skill': {'name': skill['name']}, + 'base_path': '.' if relative_path in ('', '.') else relative_path.replace(os.sep, '/'), + 'entries': entries, + 'truncated': len(entries) >= max_entries, + } + + async def read_skill_file(self, skill_name: str, path: str) -> dict: + skill = await self.get_skill(skill_name) + if not skill: + raise ValueError(f'Skill "{skill_name}" not found') + + target_path, relative_path = self._resolve_skill_path(skill, path, expect_directory=False) + if not os.path.isfile(target_path): + raise ValueError(f'Skill file not found: {relative_path}') + + try: + with open(target_path, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError as exc: + raise ValueError(f'Skill file is not valid UTF-8 text: {relative_path}') from exc + + return { + 'skill': {'name': skill['name']}, + 'path': relative_path.replace(os.sep, '/'), + 'content': content, + } + + async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict: + skill = await self.get_skill(skill_name) + if not skill: + raise ValueError(f'Skill "{skill_name}" not found') + + target_path, relative_path = self._resolve_skill_path(skill, path, expect_directory=False) + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with open(target_path, 'w', encoding='utf-8') as f: + f.write(content) + + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is not None: + refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None) + if callable(refresh_skill): + refresh_skill(skill.get('name', '')) + + return { + 'skill': {'name': skill['name']}, + 'path': relative_path.replace(os.sep, '/'), + 'bytes_written': len(content.encode('utf-8')), + } + + async def install_from_github(self, data: dict) -> list[dict]: + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_') + try: + skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir) + skill_root = self._resolve_github_source_root(skill_root, source_subdir) + previews = self._preview_skill_candidates( + skill_root, + base_target_name=repo, + suffix=release_tag.lstrip('v').replace('/', '-') or 'source', + ) + selected_previews = self._select_preview_candidates(previews, data) + scanned = self._install_preview_candidates(skill_root, selected_previews) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + await self._reload_skills() + return await self._resolve_installed_skills(scanned) + + async def preview_install_from_github(self, data: dict) -> list[dict]: + owner = str(data['owner']).strip() + repo = str(data['repo']).strip() + release_tag = str(data.get('release_tag', '')).strip() + asset_url = self._validate_github_asset_url(data['asset_url'], owner=owner, repo=repo, release_tag=release_tag) + source_subdir = str(data.get('source_subdir', '') or '').strip() + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_preview_') + try: + skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir) + skill_root = self._resolve_github_source_root(skill_root, source_subdir) + return self._preview_skill_candidates( + skill_root, + base_target_name=repo, + suffix=release_tag.lstrip('v').replace('/', '-') or 'source', + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + async def install_from_zip_upload( + self, + *, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + ) -> list[dict]: + if not file_bytes: + raise ValueError('Uploaded file is empty') + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_upload_') + try: + skill_root = self._extract_uploaded_skill_to_temp(file_bytes, tmp_dir) + base_target_name = self._uploaded_skill_target_stem(filename) + previews = self._preview_skill_candidates( + skill_root, + base_target_name=base_target_name, + suffix='upload', + ) + selected_previews = self._select_preview_candidates( + previews, + {'source_paths': source_paths or [], 'source_path': source_path}, + ) + scanned = self._install_preview_candidates(skill_root, selected_previews) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + await self._reload_skills() + return await self._resolve_installed_skills(scanned) + + async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]: + if not file_bytes: + raise ValueError('Uploaded file is empty') + + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_upload_preview_') + try: + skill_root = self._extract_uploaded_skill_to_temp(file_bytes, tmp_dir) + return self._preview_skill_candidates( + skill_root, + base_target_name=self._uploaded_skill_target_stem(filename), + suffix='upload', + ) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + async def reload_skills(self) -> list[dict]: + await self._reload_skills() + return await self.list_skills() + + def scan_directory(self, path: str) -> dict: + if not os.path.isdir(path): + raise ValueError(f'Directory does not exist: {path}') + + discovered = self._discover_skill_directories(path, max_depth=2) + if not discovered: + raise ValueError(f'No SKILL.md found in {path} or its subdirectories (max depth: 2)') + if len(discovered) > 1: + candidates = ', '.join(found_path for found_path, _entry in discovered) + raise ValueError( + f'Multiple skill directories found in {path}. Please choose a more specific path: {candidates}' + ) + + package_root, entry_file = discovered[0] + entry_path = os.path.join(package_root, entry_file) + with open(entry_path, 'r', encoding='utf-8') as f: + content = f.read() + + metadata, instructions = parse_frontmatter(content) + dir_name = os.path.basename(os.path.normpath(package_root)) + return { + 'package_root': os.path.abspath(package_root), + 'entry_file': entry_file, + 'name': str(metadata.get('name') or dir_name).strip(), + 'display_name': str(metadata.get('display_name') or '').strip(), + 'description': str(metadata.get('description') or '').strip(), + 'instructions': instructions, + 'auto_activate': bool(metadata.get('auto_activate', True)), + } + + async def _reload_skills(self) -> None: + skill_mgr = getattr(self.ap, 'skill_mgr', None) + reload_skills = getattr(skill_mgr, 'reload_skills', None) + if not callable(reload_skills): + return + result = reload_skills() + if inspect.isawaitable(result): + await result + + def _read_skill_package(self, package_root: str) -> dict: + entry = self._find_skill_entry(package_root) + if entry is None: + raise ValueError(f'No SKILL.md found in {package_root}') + + resolved_root, entry_file = entry + entry_path = os.path.join(resolved_root, entry_file) + with open(entry_path, 'r', encoding='utf-8') as f: + content = f.read() + + metadata, instructions = parse_frontmatter(content) + return { + 'entry_file': entry_file, + 'display_name': str(metadata.get('display_name') or '').strip(), + 'description': str(metadata.get('description') or '').strip(), + 'instructions': instructions, + 'auto_activate': bool(metadata.get('auto_activate', True)), + } + + async def _download_github_skill_to_temp(self, asset_url: str, tmp_dir: str) -> str: + zip_path = os.path.join(tmp_dir, 'skill.zip') + async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client: + resp = await client.get(asset_url) + resp.raise_for_status() + with open(zip_path, 'wb') as f: + f.write(resp.content) + + extract_dir = os.path.join(tmp_dir, 'extracted') + with zipfile.ZipFile(zip_path, 'r') as zf: + self._safe_extract_zip(zf, extract_dir) + + entries = os.listdir(extract_dir) + if len(entries) == 1 and os.path.isdir(os.path.join(extract_dir, entries[0])): + return os.path.join(extract_dir, entries[0]) + return extract_dir + + def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str: + extract_dir = os.path.join(tmp_dir, 'extracted') + try: + with zipfile.ZipFile(io.BytesIO(file_bytes), 'r') as zf: + self._safe_extract_zip(zf, extract_dir) + except zipfile.BadZipFile as exc: + raise ValueError('Uploaded file must be a valid .zip archive') from exc + + entries = os.listdir(extract_dir) + if len(entries) == 1 and os.path.isdir(os.path.join(extract_dir, entries[0])): + return os.path.join(extract_dir, entries[0]) + return extract_dir + + def _resolve_github_source_root(self, root_path: str, source_subdir: str) -> str: + normalized = str(source_subdir or '').strip().replace('\\', '/').strip('/') + if not normalized: + return root_path + + target_path = os.path.realpath(os.path.join(root_path, normalized)) + root_path = os.path.realpath(root_path) + if target_path != root_path and not target_path.startswith(f'{root_path}{os.sep}'): + raise ValueError('source_subdir must stay within the downloaded repository') + if not os.path.isdir(target_path): + raise ValueError(f'source_subdir does not exist in the downloaded repository: {normalized}') + return target_path + + def _uploaded_skill_target_stem(self, filename: str) -> str: + stem = os.path.splitext(os.path.basename(str(filename or '').strip()))[0] + safe_stem = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in stem).strip('-_') + if not safe_stem: + safe_stem = 'uploaded-skill' + return safe_stem + + def _build_preview_target_dir(self, base_target_name: str, source_path: str, suffix: str) -> str: + relative = str(source_path or '').strip().replace('\\', '/').strip('/') + leaf_name = relative.split('/')[-1] if relative else '' + target_name = base_target_name + if leaf_name and leaf_name != base_target_name: + target_name = f'{base_target_name}-{leaf_name}' + if suffix: + target_name = f'{target_name}-{suffix}' + return paths.get_data_path('skills', target_name) + + def _preview_skill_candidates(self, root_path: str, *, base_target_name: str, suffix: str) -> list[dict]: + discovered = self._discover_skill_directories(root_path, max_depth=2) + if not discovered: + raise ValueError(f'No SKILL.md found in {root_path} or its subdirectories (max depth: 2)') + + previews: list[dict] = [] + for package_root, entry_file in discovered: + entry_path = os.path.join(package_root, entry_file) + with open(entry_path, 'r', encoding='utf-8') as f: + content = f.read() + + metadata, instructions = parse_frontmatter(content) + relative_path = os.path.relpath(package_root, root_path) + if relative_path in ('', '.'): + relative_path = '' + + dir_name = os.path.basename(os.path.normpath(package_root)) + previews.append( + { + 'source_path': relative_path.replace(os.sep, '/'), + 'entry_file': entry_file, + 'name': str(metadata.get('name') or dir_name).strip(), + 'display_name': str(metadata.get('display_name') or '').strip(), + 'description': str(metadata.get('description') or '').strip(), + 'instructions': instructions, + 'auto_activate': bool(metadata.get('auto_activate', True)), + 'package_root': self._build_preview_target_dir(base_target_name, relative_path, suffix), + } + ) + + previews.sort(key=lambda item: item['source_path']) + return previews + + def _select_preview_candidates(self, previews: list[dict], data: dict) -> list[dict]: + normalized_paths: list[str] = [] + raw_source_paths = data.get('source_paths', []) + if isinstance(raw_source_paths, list): + for source_path in raw_source_paths: + normalized = str(source_path or '').strip().replace('\\', '/').strip('/') + if normalized not in normalized_paths: + normalized_paths.append(normalized) + + legacy_source_path = str(data.get('source_path', '') or '').strip().replace('\\', '/').strip('/') + if legacy_source_path and legacy_source_path not in normalized_paths: + normalized_paths.append(legacy_source_path) + + if len(previews) == 1 and not normalized_paths: + return previews + + if not normalized_paths: + candidates = ', '.join(item['source_path'] or '.' for item in previews) + raise ValueError(f'Multiple skills found. Please choose one or more source_paths: {candidates}') + + selected: list[dict] = [] + available = {preview['source_path']: preview for preview in previews} + for normalized_path in normalized_paths: + preview = available.get(normalized_path) + if preview is None: + candidates = ', '.join(item['source_path'] or '.' for item in previews) + raise ValueError(f'Invalid source_path "{normalized_path}". Available: {candidates}') + selected.append(preview) + + return selected + + def _install_preview_candidates(self, root_path: str, selected_previews: list[dict]) -> list[dict]: + target_dirs: list[str] = [] + for preview in selected_previews: + target_dir = self._normalize_package_root(preview['package_root']) + if target_dir in target_dirs: + raise ValueError(f'Duplicate target directory selected: {target_dir}') + if os.path.exists(target_dir): + raise ValueError(f'Skill directory already exists: {target_dir}') + target_dirs.append(target_dir) + + installed_scans: list[dict] = [] + created_dirs: list[str] = [] + try: + for preview in selected_previews: + target_dir = self._normalize_package_root(preview['package_root']) + source_root = self._preview_source_root(root_path, preview['source_path']) + os.makedirs(os.path.dirname(target_dir), exist_ok=True) + shutil.copytree(source_root, target_dir) + created_dirs.append(target_dir) + installed_scans.append(self.scan_directory(target_dir)) + except Exception: + for target_dir in created_dirs: + shutil.rmtree(target_dir, ignore_errors=True) + raise + + return installed_scans + + async def _resolve_installed_skills(self, scanned_skills: list[dict]) -> list[dict]: + installed_skills: list[dict] = [] + for scanned in scanned_skills: + installed = await self.get_skill(scanned['name']) + if not installed: + installed = self._serialize_skill(scanned) + installed_skills.append(installed) + return installed_skills + + @staticmethod + def _preview_source_root(root_path: str, source_path: str) -> str: + normalized = str(source_path or '').strip().replace('\\', '/').strip('/') + if not normalized: + return root_path + return os.path.join(root_path, normalized) + + @staticmethod + def _validate_github_asset_url(asset_url: str, *, owner: str, repo: str, release_tag: str) -> str: + parsed = urlparse(str(asset_url).strip()) + if parsed.scheme != 'https' or not parsed.netloc: + raise ValueError('asset_url must be a valid HTTPS GitHub asset URL') + + host = parsed.netloc.lower() + if host not in _GITHUB_ASSET_HOSTS: + raise ValueError('asset_url must point to a GitHub-hosted release asset or archive') + + normalized_path = posixpath.normpath(parsed.path or '/') + allowed_prefixes = [ + f'/repos/{owner}/{repo}/', + f'/{owner}/{repo}/', + ] + if not any(normalized_path.startswith(prefix) for prefix in allowed_prefixes): + raise ValueError('asset_url does not match the requested owner/repo') + + if release_tag and release_tag not in parsed.path and release_tag not in parsed.query: + raise ValueError('asset_url does not match the requested release_tag') + + return parsed.geturl() + + @staticmethod + def _safe_extract_zip(archive: zipfile.ZipFile, target_dir: str) -> None: + target_root = os.path.realpath(target_dir) + os.makedirs(target_root, exist_ok=True) + + for member in archive.infolist(): + member_name = member.filename + if not member_name or member_name.endswith('/'): + continue + + normalized = posixpath.normpath(member_name) + if normalized.startswith('../') or normalized == '..' or os.path.isabs(normalized): + raise ValueError(f'Archive contains an unsafe path: {member_name}') + + destination = os.path.realpath(os.path.join(target_root, normalized)) + if destination != target_root and not destination.startswith(f'{target_root}{os.sep}'): + raise ValueError(f'Archive contains an unsafe path: {member_name}') + + archive.extractall(target_root) + + @staticmethod + def _resolve_create_field(data: dict, field: str, imported_skill_data: dict | None, *, default: str) -> str: + raw_value = data.get(field) if field in data else None + if raw_value is None: + if imported_skill_data is not None: + return str(imported_skill_data.get(field, default) or default) + return default + + value = str(raw_value or '') + if imported_skill_data is not None and not value.strip(): + return str(imported_skill_data.get(field, default) or default) + return value + + @staticmethod + def _resolve_create_bool(data: dict, field: str, imported_skill_data: dict | None, *, default: bool) -> bool: + if field in data and data[field] is not None: + return bool(data[field]) + if imported_skill_data is not None: + return bool(imported_skill_data.get(field, default)) + return default + + def _write_skill_md(self, package_root: str, metadata: dict, instructions: str) -> None: + package_root = self._normalize_package_root(package_root) + os.makedirs(package_root, exist_ok=True) + content = _build_skill_md(metadata, instructions) + with open(os.path.join(package_root, 'SKILL.md'), 'w', encoding='utf-8') as f: + f.write(content) + + def _managed_skill_path(self, skill_name: str) -> str: + return self._normalize_package_root(paths.get_data_path('skills', skill_name)) + + def _managed_install_root_for_package(self, package_root: str) -> str: + managed_root = self._normalize_package_root(paths.get_data_path('skills')) + if not package_root or package_root == managed_root: + return '' + + prefix = f'{managed_root}{os.sep}' + if not package_root.startswith(prefix): + return '' + + relative = os.path.relpath(package_root, managed_root) + top_level = relative.split(os.sep, 1)[0] + if top_level in ('', '.', '..'): + return '' + return os.path.join(managed_root, top_level) + + @staticmethod + def _validate_skill_name(name: str) -> str: + name = str(name or '').strip() + if not name: + raise ValueError('Skill name is required') + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('Skill name can only contain letters, numbers, hyphens and underscores') + if len(name) > 64: + raise ValueError('Skill name cannot exceed 64 characters') + return name + + @staticmethod + def _normalize_package_root(package_root: str) -> str: + package_root = str(package_root).strip() + if not package_root: + return '' + return os.path.realpath(os.path.abspath(package_root)) + + @staticmethod + def _find_skill_entry(path: str) -> Optional[tuple[str, str]]: + for candidate in ('SKILL.md', 'skill.md'): + if os.path.isfile(os.path.join(path, candidate)): + return path, candidate + return None + + def _discover_skill_directories(self, root_path: str, max_depth: int = 2) -> list[tuple[str, str]]: + discovered: list[tuple[str, str]] = [] + queue: list[tuple[str, int]] = [(root_path, 0)] + seen: set[str] = set() + + while queue: + current_path, depth = queue.pop(0) + normalized_path = os.path.abspath(current_path) + if normalized_path in seen: + continue + seen.add(normalized_path) + + found = self._find_skill_entry(normalized_path) + if found: + discovered.append(found) + continue + + if depth >= max_depth: + continue + + try: + entries = sorted(os.scandir(normalized_path), key=lambda entry: entry.name) + except OSError: + continue + + for entry in entries: + if entry.is_dir(): + queue.append((entry.path, depth + 1)) + + return discovered + + def _resolve_skill_path(self, skill: dict, path: str, *, expect_directory: bool) -> tuple[str, str]: + package_root = self._normalize_package_root(skill.get('package_root', '')) + if not package_root: + raise ValueError(f'Skill "{skill.get("name", "")}" has no package_root') + + relative_path = str(path or '.').strip() or '.' + if os.path.isabs(relative_path): + raise ValueError('path must be relative to the skill package root') + + normalized_relative = os.path.normpath(relative_path) + if normalized_relative.startswith('..') or normalized_relative == '..': + raise ValueError('path must stay within the skill package root') + + target_path = os.path.realpath(os.path.join(package_root, normalized_relative)) + if target_path != package_root and not target_path.startswith(f'{package_root}{os.sep}'): + raise ValueError('path must stay within the skill package root') + + if expect_directory: + if not os.path.isdir(target_path): + raise ValueError(f'Skill directory not found: {relative_path}') + else: + parent_dir = os.path.dirname(target_path) or package_root + if parent_dir != package_root and not parent_dir.startswith(f'{package_root}{os.sep}'): + raise ValueError('path must stay within the skill package root') + + return target_path, normalized_relative diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index e25b86d9..ad48ef35 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -32,6 +32,11 @@ def _is_path_under(path: str, root: str) -> bool: return path == root or path.startswith(f'{root}{os.sep}') + +def _is_path_under(path: str, root: str) -> bool: + """Check whether *path* equals *root* or is a child of *root*.""" + return path == root or path.startswith(f'{root}{os.sep}') + if TYPE_CHECKING: from ..core import app as core_app import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index f40ecd9e..aedf4b17 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -32,7 +32,7 @@ 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 ..api.http.service import monitoring as monitoring_service - +from ..api.http.service import skill as skill_service from ..discover import engine as discover_engine from ..storage import mgr as storagemgr from ..utils import logcache @@ -43,6 +43,7 @@ from ..rag.service import RAGRuntimeService from ..vector import mgr as vectordb_mgr from ..telemetry import telemetry as telemetry_module from ..survey import manager as survey_module +from ..skill import manager as skill_mgr class Application: @@ -157,6 +158,10 @@ class Application: monitoring_service: monitoring_service.MonitoringService = None + skill_service: skill_service.SkillService = None + + skill_mgr: skill_mgr.SkillManager = None + def __init__(self): pass diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index b4a58db3..6c39627e 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -29,6 +29,8 @@ 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 ...api.http.service import monitoring as monitoring_service +from ...api.http.service import skill as skill_service +from ...skill import manager as skill_mgr from ...discover import engine as discover_engine from ...storage import mgr as storagemgr from ...utils import logcache @@ -86,6 +88,9 @@ class BuildAppStage(stage.BootingStage): webhook_service_inst = webhook_service.WebhookService(ap) ap.webhook_service = webhook_service_inst + skill_service_inst = skill_service.SkillService(ap) + ap.skill_service = skill_service_inst + proxy_mgr = proxy.ProxyManager(ap) await proxy_mgr.initialize() ap.proxy_mgr = proxy_mgr @@ -153,6 +158,11 @@ class BuildAppStage(stage.BootingStage): msg_aggregator_inst = message_aggregator.MessageAggregator(ap) ap.msg_aggregator = msg_aggregator_inst + # Initialize skill manager + skill_mgr_inst = skill_mgr.SkillManager(ap) + await skill_mgr_inst.initialize() + ap.skill_mgr = skill_mgr_inst + rag_mgr_inst = rag_mgr.RAGManager(ap) await rag_mgr_inst.initialize() ap.rag_mgr = rag_mgr_inst diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 8794786d..148ccb71 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -32,6 +32,9 @@ class PreProcessor(stage.PipelineStage): ) -> entities.StageProcessResult: """Process""" selected_runner = query.pipeline_config['ai']['runner']['runner'] + include_skill_authoring = ( + selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None + ) session = await self.ap.sess_mgr.get_session(query) @@ -89,7 +92,11 @@ class PreProcessor(stage.PipelineStage): # Get bound plugins and MCP servers for filtering tools bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + query.use_funcs = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + ) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') @@ -100,7 +107,11 @@ class PreProcessor(stage.PipelineStage): if not query.use_funcs and query.variables.get('_fallback_model_uuids'): bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers) + query.use_funcs = await self.ap.tool_mgr.get_all_tools( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=include_skill_authoring, + ) sender_name = '' @@ -210,4 +221,58 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt + # =========== Inject skill index into system prompt =========== + if selected_runner == 'local-agent' and self.ap.skill_mgr: + # Get bound skills from pipeline extensions_preferences + pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) + extensions_prefs = (pipeline_data or {}).get('extensions_preferences', {}) + enable_all_skills = extensions_prefs.get('enable_all_skills', True) + + if enable_all_skills: + bound_skills = None # None = all skills available + else: + # Get specific bound skill names + bound_skills = extensions_prefs.get('skills', []) + + # Store bound skills in query variables for runtime path visibility checks + query.variables['_pipeline_bound_skills'] = bound_skills + + # Build skill awareness addition + skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( + pipeline_uuid=query.pipeline_uuid, + bound_skills=bound_skills, + ) + + if skill_addition: + self.ap.logger.info( + f'Skill index injected into system prompt: ' + f'pipeline={query.pipeline_uuid} ' + f'bound_skills={bound_skills or "all"} ' + f'available_skills=[{", ".join(s["name"] for s in self.ap.skill_mgr.skills.values() if s.get("auto_activate", True))}]' + ) + # Append skill instruction to the first system message + if query.prompt.messages and query.prompt.messages[0].role == 'system': + if isinstance(query.prompt.messages[0].content, str): + query.prompt.messages[0].content += skill_addition + elif isinstance(query.prompt.messages[0].content, list): + # Handle content as list of ContentElements + for ce in query.prompt.messages[0].content: + if ce.type == 'text': + ce.text += skill_addition + break + else: + # Insert a new system message with skill instructions + query.prompt.messages.insert( + 0, + provider_message.Message(role='system', content=skill_addition.strip()), + ) + else: + loaded_count = len(self.ap.skill_mgr.skills) + self.ap.logger.debug( + f'No skills available for injection: ' + f'pipeline={query.pipeline_uuid} ' + f'loaded_skills={loaded_count} ' + f'bound_skills={bound_skills}' + ) + return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index 5a1189b4..f242ecc9 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -9,6 +9,7 @@ from ..tools.loaders.native import EXEC_TOOL_NAME import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.rag.context as rag_context +from ...skill.activation import get_skill_activation_coordinator rag_combined_prompt_template = """ @@ -25,6 +26,14 @@ Respond in the same language as the user's input. """ +SANDBOX_EXEC_TOOL_NAME = 'sandbox_exec' +SANDBOX_EXEC_SYSTEM_GUIDANCE = ( + 'When sandbox_exec is available, use it for exact calculations, statistics, structured data parsing, ' + 'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, ' + 'JSON, or other data and asks for a computed answer, prefer running a short Python script in sandbox_exec ' + 'and then answer from the tool result.' +) + @runner.runner_class('local-agent') class LocalAgentRunner(runner.RequestRunner): @@ -150,6 +159,8 @@ class LocalAgentRunner(runner.RequestRunner): ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: """Run request""" pending_tool_calls = [] + initial_response_emitted = False + skill_activation = get_skill_activation_coordinator(self.ap) # Get knowledge bases list from query variables (set by PreProcessor, # may have been modified by plugins during PromptPreProcessing) @@ -283,7 +294,6 @@ class LocalAgentRunner(runner.RequestRunner): query.use_funcs, remove_think, ) - yield msg final_msg = msg else: # Streaming: invoke with fallback @@ -292,6 +302,7 @@ class LocalAgentRunner(runner.RequestRunner): accumulated_content = '' last_role = 'assistant' msg_sequence = 1 + suppress_initial_stream = False stream_src, use_llm_model = await self._invoke_stream_with_fallback( query, @@ -322,7 +333,31 @@ class LocalAgentRunner(runner.RequestRunner): if tool_call.function and tool_call.function.arguments: tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments - if msg_idx % 8 == 0 or msg.is_final: + emitted_this_round = False + if skill_activation is not None: + activation_prefix_state = skill_activation.inspect_initial_content( + accumulated_content, + msg.is_final, + ) + if activation_prefix_state == 'buffer': + suppress_initial_stream = True + elif ( + activation_prefix_state == 'emit' + and suppress_initial_stream is False + and not initial_response_emitted + ): + msg_sequence += 1 + yield provider_message.MessageChunk( + role=last_role, + content=accumulated_content, + tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None, + is_final=msg.is_final, + msg_sequence=msg_sequence, + ) + initial_response_emitted = True + emitted_this_round = True + + if not suppress_initial_stream and not emitted_this_round and (msg_idx % 8 == 0 or msg.is_final): msg_sequence += 1 yield provider_message.MessageChunk( role=last_role, @@ -331,6 +366,7 @@ class LocalAgentRunner(runner.RequestRunner): is_final=msg.is_final, msg_sequence=msg_sequence, ) + initial_response_emitted = True final_msg = provider_message.MessageChunk( role=last_role, @@ -344,6 +380,118 @@ class LocalAgentRunner(runner.RequestRunner): if isinstance(final_msg, provider_message.MessageChunk): first_end_sequence = final_msg.msg_sequence + # =========== Skill activation detection =========== + # Check if the LLM response contains a skill activation marker + if first_content and skill_activation is not None: + activation_plan = None + original_req_messages_len = len(req_messages) + + try: + activation_plan = skill_activation.prepare_followup(query, first_content) + if activation_plan: + self.ap.logger.info(f'Skill activations detected: {activation_plan.activated_skill_names}') + + # Reconstruct messages with a sanitized activation response, then add the skill prompt. + sanitized_activation_msg = provider_message.Message( + role=getattr(final_msg, 'role', 'assistant'), + content=activation_plan.cleaned_content, + tool_calls=getattr(final_msg, 'tool_calls', None), + ) + req_messages.append(sanitized_activation_msg) + req_messages.append(activation_plan.system_message) + + # Make another request to let the LLM execute the skill + if is_stream: + tool_calls_map = {} + msg_idx = 0 + accumulated_content = '' + last_role = 'assistant' + msg_sequence = first_end_sequence + + async for msg in use_llm_model.provider.invoke_llm_stream( + query, + use_llm_model, + req_messages, + query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], + extra_args=use_llm_model.model_entity.extra_args, + remove_think=remove_think, + ): + msg_idx += 1 + + if msg.role: + last_role = msg.role + + if msg.content: + accumulated_content += msg.content + + if msg.tool_calls: + for tool_call in msg.tool_calls: + if tool_call.id not in tool_calls_map: + tool_calls_map[tool_call.id] = provider_message.ToolCall( + id=tool_call.id, + type=tool_call.type, + function=provider_message.FunctionCall( + name=tool_call.function.name if tool_call.function else '', + arguments='', + ), + ) + if tool_call.function and tool_call.function.arguments: + tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments + + if msg_idx % 8 == 0 or msg.is_final: + msg_sequence += 1 + yield provider_message.MessageChunk( + role=last_role, + content=accumulated_content, + tool_calls=list(tool_calls_map.values()) + if (tool_calls_map and msg.is_final) + else None, + is_final=msg.is_final, + msg_sequence=msg_sequence, + ) + initial_response_emitted = True + + final_msg = provider_message.MessageChunk( + role=last_role, + content=accumulated_content, + tool_calls=list(tool_calls_map.values()) if tool_calls_map else None, + msg_sequence=msg_sequence, + ) + first_content = accumulated_content + first_end_sequence = msg_sequence + else: + msg = await use_llm_model.provider.invoke_llm( + query, + use_llm_model, + req_messages, + query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [], + extra_args=use_llm_model.model_entity.extra_args, + remove_think=remove_think, + ) + final_msg = msg + first_content = msg.content + + # Update pending tool calls from the new response + pending_tool_calls = final_msg.tool_calls + # Remove the sanitized activation message and follow-up system prompt. + req_messages = req_messages[:-2] + except Exception: + self.ap.logger.exception('Skill activation failed, falling back to normal execution') + skill_activation.rollback( + query, + activation_plan.snapshot if activation_plan is not None else None, + final_msg, + ) + req_messages = req_messages[:original_req_messages_len] + first_content = final_msg.content + + if not is_stream: + yield final_msg + initial_response_emitted = True + elif not initial_response_emitted: + yield final_msg + initial_response_emitted = True + req_messages.append(final_msg) # Once a model succeeds, commit to it for the tool call loop diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 3433345a..582c2a60 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool from langbot_plugin.api.entities.events import pipeline_query from .. import loader +from . import skill as skill_loader EXEC_TOOL_NAME = 'exec' READ_TOOL_NAME = 'read' @@ -43,44 +44,116 @@ class NativeToolLoader(loader.ToolLoader): f'query_id={query.query_id} ' f'parameters={json.dumps(self._summarize_parameters(parameters), ensure_ascii=False)}' ) - return await self.ap.box_service.execute_tool(parameters, query) - elif name == READ_TOOL_NAME: + return await self._invoke_exec(parameters, query) + if name == READ_TOOL_NAME: return await self._invoke_read(parameters, query) - elif name == WRITE_TOOL_NAME: + if name == WRITE_TOOL_NAME: return await self._invoke_write(parameters, query) - elif name == EDIT_TOOL_NAME: + if name == EDIT_TOOL_NAME: return await self._invoke_edit(parameters, query) - else: - raise ValueError(f'未找到工具: {name}') + raise ValueError(f'未找到工具: {name}') async def shutdown(self): pass - # ── File tool implementations ──────────────────────────────────── + async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict: + command = str(parameters['command']) + workdir = str(parameters.get('workdir', '/workspace') or '/workspace') + + selected_skill, rewritten_workdir = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + workdir, + include_visible=False, + include_activated=True, + ) + referenced_skill_names = skill_loader.find_referenced_skill_names(command) + + if selected_skill is None and referenced_skill_names: + if len(referenced_skill_names) > 1: + raise ValueError('exec can target at most one activated skill package per call.') + selected_skill = skill_loader.get_activated_skill(query, referenced_skill_names[0]) + if selected_skill is None: + raise ValueError( + f'Skill "{referenced_skill_names[0]}" must be activated before exec can run in its package.' + ) + rewritten_workdir = '/workspace' + + if selected_skill is None: + return await self.ap.box_service.execute_tool(parameters, query) + + selected_skill_name = str(selected_skill.get('name', '') or '') + if referenced_skill_names and any(name != selected_skill_name for name in referenced_skill_names): + raise ValueError('exec can reference files from only one activated skill package per call.') + + package_root = str(selected_skill.get('package_root', '') or '').strip() + if not package_root: + raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.') + + rewritten_command = skill_loader.rewrite_command_for_skill_mount(command, selected_skill_name) + if skill_loader.should_prepare_skill_python_env(package_root): + rewritten_command = skill_loader.wrap_skill_command_with_python_env(rewritten_command) + + spec_payload: dict = { + 'cmd': rewritten_command, + 'workdir': rewritten_workdir, + 'host_path': package_root, + 'host_path_mode': 'rw', + 'session_id': skill_loader.build_skill_session_id(selected_skill, query), + } + for key in ('timeout_sec', 'env'): + if key in parameters: + spec_payload[key] = parameters[key] + + result = await self.ap.box_service.execute_spec_payload(spec_payload, query) + self._refresh_skill_from_disk(selected_skill) + return result + + def _resolve_host_path( + self, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, + ) -> tuple[str, dict | None]: + selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + sandbox_path, + include_visible=include_visible, + include_activated=include_activated, + ) - def _resolve_host_path(self, sandbox_path: str) -> str: - """Map a sandbox /workspace path to the host filesystem path.""" box_service = self.ap.box_service - host_root = box_service.default_host_workspace - if host_root is None: - raise ValueError('No default host workspace configured for file operations.') + host_root = ( + selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace + ) + if not host_root: + raise ValueError('No host workspace configured for file operations.') mount_path = '/workspace' - if not sandbox_path.startswith(mount_path): + if not rewritten_path.startswith(mount_path): raise ValueError(f'Path must be under {mount_path}.') - relative = sandbox_path[len(mount_path):].lstrip('/') + relative = rewritten_path[len(mount_path) :].lstrip('/') host_path = os.path.realpath(os.path.join(host_root, relative)) + host_root = os.path.realpath(host_root) if not (host_path == host_root or host_path.startswith(host_root + os.sep)): raise ValueError('Path escapes the workspace boundary.') - return host_path + return host_path, selected_skill async def _invoke_read(self, parameters: dict, query: pipeline_query.Query) -> dict: path = parameters['path'] self.ap.logger.info(f'read tool invoked: query_id={query.query_id} path={path}') - host_path = self._resolve_host_path(path) + host_path, _selected_skill = self._resolve_host_path( + query, + path, + include_visible=True, + include_activated=True, + ) if not os.path.exists(host_path): return {'ok': False, 'error': f'File not found: {path}'} if os.path.isdir(host_path): @@ -94,10 +167,16 @@ class NativeToolLoader(loader.ToolLoader): path = parameters['path'] content = parameters['content'] self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}') - host_path = self._resolve_host_path(path) + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=False, + include_activated=True, + ) os.makedirs(os.path.dirname(host_path), exist_ok=True) - with open(host_path, 'w') as f: + with open(host_path, 'w', encoding='utf-8') as f: f.write(content) + self._refresh_skill_from_disk(selected_skill) return {'ok': True, 'path': path} async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict: @@ -108,10 +187,15 @@ class NativeToolLoader(loader.ToolLoader): f'edit tool invoked: query_id={query.query_id} path={path} ' f'old_len={len(old_string)} new_len={len(new_string)}' ) - host_path = self._resolve_host_path(path) + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=False, + include_activated=True, + ) if not os.path.isfile(host_path): return {'ok': False, 'error': f'File not found: {path}'} - with open(host_path, 'r', errors='replace') as f: + with open(host_path, 'r', encoding='utf-8', errors='replace') as f: content = f.read() count = content.count(old_string) if count == 0: @@ -119,11 +203,22 @@ class NativeToolLoader(loader.ToolLoader): if count > 1: return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'} new_content = content.replace(old_string, new_string, 1) - with open(host_path, 'w') as f: + with open(host_path, 'w', encoding='utf-8') as f: f.write(new_content) + self._refresh_skill_from_disk(selected_skill) return {'ok': True, 'path': path} - # ── Internals ──────────────────────────────────────────────────── + def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None: + if selected_skill is None: + return + + skill_mgr = getattr(self.ap, 'skill_mgr', None) + if skill_mgr is None: + return + + refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None) + if callable(refresh_skill): + refresh_skill(selected_skill.get('name', '')) def _is_sandbox_available(self) -> bool: box_service = getattr(self.ap, 'box_service', None) @@ -135,8 +230,10 @@ class NativeToolLoader(loader.ToolLoader): human_desc='Execute a command in an isolated environment', description=( 'Run shell commands in an isolated execution environment. ' - 'Use this tool for bash commands, Python execution, and exact calculations ' - 'over user-provided data.' + 'Use this tool for bash commands, Python execution, and exact calculations over ' + 'user-provided data. Activated skill packages are addressable under ' + '/workspace/.skills/; when running inside one, set workdir to that path. ' + 'To create a new skill package, prepare it under /workspace first, then use import_skill_from_directory.' ), parameters={ 'type': 'object', @@ -147,9 +244,7 @@ class NativeToolLoader(loader.ToolLoader): }, 'workdir': { 'type': 'string', - 'description': ( - 'Working directory for the command. Defaults to /workspace.' - ), + 'description': 'Working directory for the command. Defaults to /workspace.', 'default': '/workspace', }, 'timeout_sec': { @@ -179,7 +274,10 @@ class NativeToolLoader(loader.ToolLoader): return resource_tool.LLMTool( name=READ_TOOL_NAME, human_desc='Read a file from the workspace', - description='Read the contents of a file at the given path under /workspace.', + description=( + 'Read the contents of a file at the given path under /workspace. ' + 'Visible skill packages can be inspected through /workspace/.skills//... .' + ), parameters={ 'type': 'object', 'properties': { @@ -198,7 +296,11 @@ class NativeToolLoader(loader.ToolLoader): return resource_tool.LLMTool( name=WRITE_TOOL_NAME, human_desc='Write a file to the workspace', - description='Create or overwrite a file at the given path under /workspace with the provided content.', + description=( + 'Create or overwrite a file at the given path under /workspace with the provided content. ' + 'Activated skill packages can be modified through /workspace/.skills//... . ' + 'For new skills, write files under /workspace and then call import_skill_from_directory.' + ), parameters={ 'type': 'object', 'properties': { @@ -223,7 +325,9 @@ class NativeToolLoader(loader.ToolLoader): human_desc='Edit a file in the workspace', description=( 'Perform an exact string replacement in a file under /workspace. ' - 'The old_string must appear exactly once in the file.' + 'The old_string must appear exactly once in the file. Activated skill packages ' + 'can be edited through /workspace/.skills//... . ' + 'For new skills, edit files under /workspace and then call import_skill_from_directory.' ), parameters={ 'type': 'object', diff --git a/src/langbot/pkg/provider/tools/loaders/skill.py b/src/langbot/pkg/provider/tools/loaders/skill.py new file mode 100644 index 00000000..48e66c4b --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/skill.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import os +import re +import textwrap +import typing + +if typing.TYPE_CHECKING: + from ....core import app + from langbot_plugin.api.entities.events import pipeline_query + +ACTIVATED_SKILLS_KEY = '_activated_skills' +PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills' +SKILL_MOUNT_PREFIX = '/workspace/.skills' +_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)') +_PYTHON_SKILL_MANIFESTS = ( + 'requirements.txt', + 'pyproject.toml', + 'setup.py', + 'setup.cfg', +) + + +def _normalize_host_path(path: str | None) -> str: + if path is None: + return '' + stripped = str(path).strip() + if not stripped: + return '' + return os.path.realpath(os.path.abspath(stripped)) + + +def get_virtual_skill_mount_path(skill_name: str) -> str: + return f'{SKILL_MOUNT_PREFIX}/{skill_name}' + + +def get_bound_skill_names(query: pipeline_query.Query) -> list[str] | None: + if query.variables is None: + return None + + bound_skills = query.variables.get(PIPELINE_BOUND_SKILLS_KEY) + if bound_skills is None: + return None + if isinstance(bound_skills, list): + return [str(item) for item in bound_skills] + return None + + +def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict[str, dict]: + skill_mgr = getattr(ap, 'skill_mgr', None) + if skill_mgr is None: + return {} + + visible_skills = getattr(skill_mgr, 'skills', {}) + bound_skills = get_bound_skill_names(query) + if bound_skills is None: + return visible_skills + + return {skill_name: skill_data for skill_name, skill_data in visible_skills.items() if skill_name in bound_skills} + + +def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> dict | None: + return get_visible_skills(ap, query).get(skill_name) + + +def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]: + if query.variables is None: + return {} + + activated = query.variables.get(ACTIVATED_SKILLS_KEY, {}) + if not isinstance(activated, dict): + return {} + return activated + + +def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict | None: + return get_activated_skills(query).get(skill_name) + + +def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None: + if query.variables is None: + query.variables = {} + + activated = query.variables.setdefault(ACTIVATED_SKILLS_KEY, {}) + skill_name = str(skill_data.get('name', '') or '').strip() + if skill_name and skill_name not in activated: + activated[skill_name] = skill_data + + +def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]: + normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace' + if normalized_path == SKILL_MOUNT_PREFIX: + raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/.') + prefix = f'{SKILL_MOUNT_PREFIX}/' + if not normalized_path.startswith(prefix): + return None, normalized_path + + remainder = normalized_path[len(prefix) :] + skill_name, separator, tail = remainder.partition('/') + if not skill_name: + raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/.') + + rewritten_path = '/workspace' + if separator: + rewritten_path = f'/workspace/{tail}' + return skill_name, rewritten_path + + +def resolve_virtual_skill_path( + ap: app.Application, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, +) -> tuple[dict | None, str]: + skill_name, rewritten_path = parse_skill_mount_path(sandbox_path) + if skill_name is None: + return None, rewritten_path + + if include_activated: + activated_skill = get_activated_skill(query, skill_name) + if activated_skill is not None: + return activated_skill, rewritten_path + + if include_visible: + visible_skill = get_visible_skill(ap, query, skill_name) + if visible_skill is not None: + return visible_skill, rewritten_path + + activated_names = ', '.join(sorted(get_activated_skills(query).keys())) or 'none' + visible_names = ', '.join(sorted(get_visible_skills(ap, query).keys())) or 'none' + raise ValueError( + f'Skill "{skill_name}" is not available at this path. ' + f'Activated skills: {activated_names}. Visible skills: {visible_names}.' + ) + + +def find_referenced_skill_names(text: str) -> list[str]: + if not text: + return [] + + seen: list[str] = [] + for match in _SKILL_MOUNT_PATTERN.findall(text): + if match not in seen: + seen.append(match) + return seen + + +def rewrite_command_for_skill_mount(command: str, skill_name: str) -> str: + virtual_root = get_virtual_skill_mount_path(skill_name) + rewritten = command.replace(f'{virtual_root}/', '/workspace/') + return rewritten.replace(virtual_root, '/workspace') + + +def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str: + skill_identifier = str(skill_data.get('name', 'unknown') or 'unknown') + launcher_type = getattr(query, 'launcher_type', None) + launcher_id = getattr(query, 'launcher_id', None) + query_id = getattr(query, 'query_id', 'unknown') + + if launcher_type is not None and launcher_id is not None: + return f'skill-{launcher_type}_{launcher_id}-{skill_identifier}' + return f'skill-{query_id}-{skill_identifier}' + + +def should_prepare_skill_python_env(package_root: str | None) -> bool: + normalized_root = _normalize_host_path(package_root) + if not normalized_root: + return False + if os.path.isdir(os.path.join(normalized_root, '.venv')): + return True + return any(os.path.isfile(os.path.join(normalized_root, filename)) for filename in _PYTHON_SKILL_MANIFESTS) + + +def wrap_skill_command_with_python_env(command: str) -> str: + bootstrap = textwrap.dedent( + """ + set -e + + _LB_VENV_DIR="/workspace/.venv" + _LB_META_DIR="/workspace/.langbot" + _LB_META_FILE="$_LB_META_DIR/python-env.json" + _LB_LOCK_DIR="$_LB_META_DIR/python-env.lock" + _LB_TMP_DIR="/workspace/.tmp" + _LB_PIP_CACHE_DIR="/workspace/.cache/pip" + + mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR" + export TMPDIR="$_LB_TMP_DIR" + export TEMP="$_LB_TMP_DIR" + export TMP="$_LB_TMP_DIR" + export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR" + + _lb_python_meta() { + python - <<'PY' + import hashlib + import json + import os + import sys + + root = "/workspace" + digest = hashlib.sha256() + manifest_files = [] + for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"): + path = os.path.join(root, rel) + if not os.path.isfile(path): + continue + manifest_files.append(rel) + with open(path, "rb") as handle: + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + digest.update(handle.read()) + digest.update(b"\0") + + print( + json.dumps( + { + "python_executable": sys.executable, + "python_version": list(sys.version_info[:3]), + "manifest_files": manifest_files, + "manifest_sha256": digest.hexdigest(), + }, + sort_keys=True, + ) + ) + PY + } + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + _LB_LOCK_WAIT=0 + while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do + if [ "$_LB_LOCK_WAIT" -ge 120 ]; then + echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2 + exit 1 + fi + sleep 1 + _LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1)) + done + + _lb_cleanup_lock() { + rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true + } + trap _lb_cleanup_lock EXIT INT TERM + + _LB_CURRENT_META="$(_lb_python_meta)" + _LB_NEEDS_BOOTSTRAP=0 + if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ ! -f "$_LB_META_FILE" ]; then + _LB_NEEDS_BOOTSTRAP=1 + elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then + _LB_NEEDS_BOOTSTRAP=1 + fi + + if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then + rm -rf "$_LB_VENV_DIR" + python -m venv "$_LB_VENV_DIR" + + if [ -f /workspace/requirements.txt ]; then + "$_LB_VENV_DIR/bin/python" -m pip install -r /workspace/requirements.txt + elif [ -f /workspace/pyproject.toml ] || [ -f /workspace/setup.py ] || [ -f /workspace/setup.cfg ]; then + "$_LB_VENV_DIR/bin/python" -m pip install -e /workspace + fi + + printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE" + fi + fi + + export VIRTUAL_ENV="$_LB_VENV_DIR" + export PATH="$_LB_VENV_DIR/bin:$PATH" + """ + ).strip() + + return f'{bootstrap}\n\n{command}' diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py new file mode 100644 index 00000000..2c54454a --- /dev/null +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import os +import typing + +import langbot_plugin.api.entities.builtin.resource.tool as resource_tool + +from .. import loader + +# Skill authoring needs a managed abstraction above the generic box tools. +# Pure prompt skills are just metadata plus SKILL.md instructions, so creating +# or updating them should not require /workspace mounts, shell access, or box +# to be enabled at all. These higher-level tools let local agents manage skills +# directly through SkillService, while import_skill_from_directory remains the +# path for file-based skills that actually need scripts or assets from box. + +CREATE_SKILL_TOOL_NAME = 'create_skill' +LIST_SKILLS_TOOL_NAME = 'list_skills' +GET_SKILL_TOOL_NAME = 'get_skill' +UPDATE_SKILL_TOOL_NAME = 'update_skill' +DELETE_SKILL_TOOL_NAME = 'delete_skill' +IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME = 'import_skill_from_directory' +RELOAD_SKILLS_TOOL_NAME = 'reload_skills' + +AUTHORING_TOOL_NAMES = { + CREATE_SKILL_TOOL_NAME, + LIST_SKILLS_TOOL_NAME, + GET_SKILL_TOOL_NAME, + UPDATE_SKILL_TOOL_NAME, + DELETE_SKILL_TOOL_NAME, + IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + RELOAD_SKILLS_TOOL_NAME, +} + + +class SkillAuthoringToolLoader(loader.ToolLoader): + """Minimal system actions for filesystem-backed skills.""" + + def __init__(self, ap): + super().__init__(ap) + self._tools: list[resource_tool.LLMTool] = [] + + async def initialize(self): + self._tools = [ + self._build_create_skill_tool(), + self._build_list_skills_tool(), + self._build_get_skill_tool(), + self._build_update_skill_tool(), + self._build_delete_skill_tool(), + self._build_import_skill_from_directory_tool(), + self._build_reload_skills_tool(), + ] + + async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]: + if not self._has_authoring_services(): + return [] + return list(self._tools) + + async def has_tool(self, name: str) -> bool: + return self._has_authoring_services() and name in AUTHORING_TOOL_NAMES + + async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any: + if name == CREATE_SKILL_TOOL_NAME: + return await self._invoke_create_skill(parameters) + if name == LIST_SKILLS_TOOL_NAME: + return await self._invoke_list_skills() + if name == GET_SKILL_TOOL_NAME: + return await self._invoke_get_skill(parameters) + if name == UPDATE_SKILL_TOOL_NAME: + return await self._invoke_update_skill(parameters) + if name == DELETE_SKILL_TOOL_NAME: + return await self._invoke_delete_skill(parameters) + if name == IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME: + return await self._invoke_import_skill_from_directory(parameters) + if name == RELOAD_SKILLS_TOOL_NAME: + return await self._invoke_reload_skills() + raise ValueError(f'Unknown skill authoring tool: {name}') + + async def shutdown(self): + pass + + def _has_authoring_services(self) -> bool: + return getattr(self.ap, 'skill_service', None) is not None + + async def _invoke_reload_skills(self) -> typing.Any: + await self.ap.skill_service.reload_skills() + skills = await self.ap.skill_service.list_skills() + return { + 'reloaded': True, + 'skill_names': [skill['name'] for skill in skills], + 'count': len(skills), + } + + async def _invoke_create_skill(self, parameters: dict) -> typing.Any: + name = str(parameters.get('name', '') or '').strip() + instructions = str(parameters.get('instructions', '') or '') + if not name: + raise ValueError('name is required') + if not instructions.strip(): + raise ValueError('instructions is required') + + created = await self.ap.skill_service.create_skill( + { + 'name': name, + 'display_name': str(parameters.get('display_name', '') or '').strip(), + 'description': str(parameters.get('description', '') or '').strip(), + 'instructions': instructions, + 'auto_activate': parameters.get('auto_activate', True), + } + ) + return { + 'created': True, + 'skill': created, + } + + async def _invoke_list_skills(self) -> typing.Any: + skills = await self.ap.skill_service.list_skills() + return { + 'skills': skills, + 'skill_names': [skill['name'] for skill in skills], + 'count': len(skills), + } + + async def _invoke_get_skill(self, parameters: dict) -> typing.Any: + name = str(parameters.get('name', '') or '').strip() + if not name: + raise ValueError('name is required') + + skill = await self.ap.skill_service.get_skill(name) + if not skill: + raise ValueError(f'Skill "{name}" not found') + return {'skill': skill} + + async def _invoke_update_skill(self, parameters: dict) -> typing.Any: + name = str(parameters.get('name', '') or '').strip() + if not name: + raise ValueError('name is required') + + data = {'name': name} + for field in ('display_name', 'description', 'instructions', 'auto_activate'): + if field in parameters: + data[field] = parameters[field] + + updated = await self.ap.skill_service.update_skill(name, data) + return { + 'updated': True, + 'skill': updated, + } + + async def _invoke_delete_skill(self, parameters: dict) -> typing.Any: + name = str(parameters.get('name', '') or '').strip() + if not name: + raise ValueError('name is required') + + await self.ap.skill_service.delete_skill(name) + return { + 'deleted': True, + 'skill_name': name, + } + + async def _invoke_import_skill_from_directory(self, parameters: dict) -> typing.Any: + sandbox_path = str(parameters.get('path', '') or '').strip() + if not sandbox_path: + raise ValueError('path is required') + + host_path = self._resolve_workspace_directory(sandbox_path) + scanned = self.ap.skill_service.scan_directory(host_path) + created = await self.ap.skill_service.create_skill( + { + 'name': str(parameters.get('name') or scanned['name']).strip(), + '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, + 'auto_activate': parameters.get('auto_activate', scanned.get('auto_activate', True)), + } + ) + return { + 'imported': True, + 'source_path': sandbox_path, + 'skill': created, + } + + def _resolve_workspace_directory(self, sandbox_path: str) -> str: + box_service = getattr(self.ap, 'box_service', None) + workspace_root = getattr(box_service, 'default_host_workspace', None) + if not workspace_root: + raise ValueError('No default host workspace configured for importing skills') + + normalized_path = str(sandbox_path).strip() or '/workspace' + if not normalized_path.startswith('/workspace'): + raise ValueError('path must be under /workspace') + + relative = normalized_path[len('/workspace') :].lstrip('/') + host_root = os.path.realpath(workspace_root) + host_path = os.path.realpath(os.path.join(host_root, relative)) + if not (host_path == host_root or host_path.startswith(host_root + os.sep)): + raise ValueError('path escapes the workspace boundary') + if not os.path.isdir(host_path): + raise ValueError(f'Directory does not exist: {sandbox_path}') + return host_path + + def _build_create_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=CREATE_SKILL_TOOL_NAME, + human_desc='Create a managed skill', + description=( + 'Create a new managed skill directly in the skills store without using /workspace. ' + 'Use this for prompt-only skills or simple skills whose main content is the SKILL.md instructions. ' + 'Pure prompt skills should not depend on box or a workspace directory just to be created or edited later.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Skill name. Use lowercase letters, numbers, hyphens, or underscores.', + }, + 'display_name': { + 'type': 'string', + 'description': 'Optional human-friendly display name.', + }, + 'description': { + 'type': 'string', + 'description': 'Optional concise description of what the skill does and when to use it.', + }, + 'instructions': { + 'type': 'string', + 'description': 'The SKILL.md body instructions for the new skill.', + }, + 'auto_activate': { + 'type': 'boolean', + 'description': 'Whether the skill should be considered for automatic activation. Defaults to true.', + }, + }, + 'required': ['name', 'instructions'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_list_skills_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=LIST_SKILLS_TOOL_NAME, + human_desc='List managed skills', + description='List all managed skills so you can inspect what already exists before creating, updating, or deleting one.', + parameters={ + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_get_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=GET_SKILL_TOOL_NAME, + human_desc='Get a managed skill', + description='Fetch one managed skill by name, including its current metadata and instructions, without relying on /workspace or skill activation.', + parameters={ + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Existing skill name to fetch.', + }, + }, + 'required': ['name'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_update_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=UPDATE_SKILL_TOOL_NAME, + human_desc='Update a managed skill', + description=( + 'Update an existing managed skill directly in the skills store without using /workspace. ' + 'Use this for prompt-only skills or for metadata and instruction changes to an existing skill. ' + 'Pure prompt skills should remain editable through managed skill tools instead of depending on box.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Existing skill name to update.', + }, + 'display_name': { + 'type': 'string', + 'description': 'Optional new human-friendly display name.', + }, + 'description': { + 'type': 'string', + 'description': 'Optional new concise description.', + }, + 'instructions': { + 'type': 'string', + 'description': 'Optional replacement SKILL.md body instructions.', + }, + 'auto_activate': { + 'type': 'boolean', + 'description': 'Optional new auto_activate value.', + }, + }, + 'required': ['name'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_delete_skill_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=DELETE_SKILL_TOOL_NAME, + human_desc='Delete a managed skill', + description='Delete an existing managed skill by name from the managed skills store.', + parameters={ + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Existing skill name to delete.', + }, + }, + 'required': ['name'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_import_skill_from_directory_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + human_desc='Import skill from workspace directory', + description=( + 'Import a skill package from a directory under /workspace into the managed skills store. ' + 'Use this after cloning or preparing a skill repository in the default workspace. ' + 'This is for file-based skills that actually need scripts, assets, or extra files. ' + 'Pure prompt skills should use create_skill or update_skill instead of depending on box. ' + 'If the source directory is already under the managed skills root, it will be registered in place instead of copied again.' + ), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': 'string', + 'description': 'Directory path under /workspace that contains a skill package or a nested SKILL.md.', + }, + 'name': { + 'type': 'string', + 'description': 'Optional skill name override. Defaults to the scanned skill name.', + }, + 'display_name': { + 'type': 'string', + 'description': 'Optional display name override.', + }, + 'description': { + 'type': 'string', + 'description': 'Optional description override.', + }, + 'instructions': { + 'type': 'string', + 'description': 'Optional instructions override.', + }, + 'auto_activate': { + 'type': 'boolean', + 'description': 'Optional auto_activate override.', + }, + }, + 'required': ['path'], + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) + + def _build_reload_skills_tool(self) -> resource_tool.LLMTool: + return resource_tool.LLMTool( + name=RELOAD_SKILLS_TOOL_NAME, + human_desc='Reload filesystem skills', + description=( + 'Reload skills from the filesystem after using the standard exec/read/write/edit tools ' + 'to create, rename, or modify skill packages under the managed skills directory.' + ), + parameters={ + 'type': 'object', + 'properties': {}, + 'additionalProperties': False, + }, + func=lambda parameters: parameters, + ) diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index e652b388..cdab2867 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -8,7 +8,12 @@ from langbot_plugin.api.entities.events import pipeline_query if TYPE_CHECKING: from ...core import app - from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) class ToolManager: @@ -19,6 +24,7 @@ class ToolManager: native_tool_loader: native_loader.NativeToolLoader plugin_tool_loader: plugin_loader.PluginToolLoader mcp_tool_loader: mcp_loader.MCPLoader + skill_authoring_tool_loader: skill_authoring_loader.SkillAuthoringToolLoader def __init__(self, ap: app.Application): self.ap = ap @@ -26,7 +32,12 @@ class ToolManager: async def initialize(self): from langbot.pkg.utils import importutil from langbot.pkg.provider.tools import loaders - from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader + from langbot.pkg.provider.tools.loaders import ( + mcp as mcp_loader, + native as native_loader, + plugin as plugin_loader, + skill_authoring as skill_authoring_loader, + ) importutil.import_modules_in_pkg(loaders) @@ -36,21 +47,26 @@ class ToolManager: await self.plugin_tool_loader.initialize() self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap) await self.mcp_tool_loader.initialize() + self.skill_authoring_tool_loader = skill_authoring_loader.SkillAuthoringToolLoader(self.ap) + await self.skill_authoring_tool_loader.initialize() async def get_all_tools( - self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = False, ) -> list[resource_tool.LLMTool]: - """获取所有函数""" all_functions: list[resource_tool.LLMTool] = [] all_functions.extend(await self.native_tool_loader.get_tools()) + if include_skill_authoring: + all_functions.extend(await self.skill_authoring_tool_loader.get_tools()) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) return all_functions async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list: - """生成函数列表""" tools = [] for function in use_funcs: @@ -67,28 +83,6 @@ class ToolManager: return tools async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list: - """为anthropic生成函数列表 - - e.g. - - [ - { - "name": "get_stock_price", - "description": "Get the current stock price for a given ticker symbol.", - "input_schema": { - "type": "object", - "properties": { - "ticker": { - "type": "string", - "description": "The stock ticker symbol, e.g. AAPL for Apple Inc." - } - }, - "required": ["ticker"] - } - } - ] - """ - tools = [] for function in use_funcs: @@ -102,19 +96,18 @@ class ToolManager: return tools async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any: - """执行函数调用""" - if await self.native_tool_loader.has_tool(name): return await self.native_tool_loader.invoke_tool(name, parameters, query) - elif await self.plugin_tool_loader.has_tool(name): + if await self.plugin_tool_loader.has_tool(name): return await self.plugin_tool_loader.invoke_tool(name, parameters, query) - elif await self.mcp_tool_loader.has_tool(name): + if await self.mcp_tool_loader.has_tool(name): return await self.mcp_tool_loader.invoke_tool(name, parameters, query) - else: - raise ValueError(f'未找到工具: {name}') + if await self.skill_authoring_tool_loader.has_tool(name): + return await self.skill_authoring_tool_loader.invoke_tool(name, parameters, query) + raise ValueError(f'未找到工具: {name}') async def shutdown(self): - """关闭所有工具""" await self.native_tool_loader.shutdown() await self.plugin_tool_loader.shutdown() await self.mcp_tool_loader.shutdown() + await self.skill_authoring_tool_loader.shutdown() diff --git a/src/langbot/pkg/skill/__init__.py b/src/langbot/pkg/skill/__init__.py new file mode 100644 index 00000000..b96f23ca --- /dev/null +++ b/src/langbot/pkg/skill/__init__.py @@ -0,0 +1,3 @@ +from .manager import SkillManager + +__all__ = ['SkillManager'] diff --git a/src/langbot/pkg/skill/activation.py b/src/langbot/pkg/skill/activation.py new file mode 100644 index 00000000..6fd9fd93 --- /dev/null +++ b/src/langbot/pkg/skill/activation.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +import typing + +import langbot_plugin.api.entities.builtin.provider.message as provider_message + +from ..provider.tools.loaders import skill as skill_loader + +if typing.TYPE_CHECKING: + from ..core import app + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query + + +@dataclass +class PreparedSkillActivation: + activated_skill_names: list[str] + cleaned_content: str + prompt: str + + +@dataclass +class SkillActivationSnapshot: + use_funcs: list | None + variables: dict | None + + +@dataclass +class SkillActivationPlan: + activated_skill_names: list[str] + cleaned_content: str + system_message: provider_message.Message + snapshot: SkillActivationSnapshot + + +class SkillActivationCoordinator: + """Owns the skill activation protocol around the local-agent runner.""" + + def __init__(self, ap: app.Application, skill_mgr: typing.Any): + self.ap = ap + self.skill_mgr = skill_mgr + + def inspect_initial_content(self, content: str | None, is_final: bool) -> str: + if not content: + return 'emit' + + stripped = content.lstrip() + if not stripped: + return 'undecided' + + marker = str(getattr(self.skill_mgr, 'SKILL_ACTIVATION_MARKER', '[ACTIVATE_SKILL:')) + if stripped.startswith(marker): + return 'buffer' + if not is_final and marker.startswith(stripped): + return 'undecided' + return 'emit' + + def prepare_followup( + self, + query: pipeline_query.Query, + response_content: str | None, + ) -> SkillActivationPlan | None: + snapshot = self._snapshot_query_state(query) + try: + activation = prepare_skill_activation(self.ap, query, response_content) + except Exception: + self._restore_query_state(query, snapshot) + raise + + if not activation: + return None + + return SkillActivationPlan( + activated_skill_names=activation.activated_skill_names, + cleaned_content=activation.cleaned_content, + system_message=provider_message.Message(role='system', content=activation.prompt), + snapshot=snapshot, + ) + + def rollback( + self, + query: pipeline_query.Query, + snapshot: SkillActivationSnapshot | None, + response_message: provider_message.Message | provider_message.MessageChunk | None, + ) -> None: + if snapshot is not None: + self._restore_query_state(query, snapshot) + + if response_message is None or not isinstance(response_message.content, str): + return + + response_message.content = self.skill_mgr.remove_activation_marker(response_message.content) + + @staticmethod + def _snapshot_use_funcs(use_funcs: list | None) -> list | None: + if use_funcs is None: + return None + return list(use_funcs) + + def _snapshot_query_state(self, query: pipeline_query.Query) -> SkillActivationSnapshot: + return SkillActivationSnapshot( + use_funcs=self._snapshot_use_funcs(query.use_funcs), + variables=copy.deepcopy(query.variables) if query.variables is not None else None, + ) + + @staticmethod + def _restore_query_state(query: pipeline_query.Query, snapshot: SkillActivationSnapshot) -> None: + query.use_funcs = snapshot.use_funcs + query.variables = snapshot.variables + + +def prepare_skill_activation( + ap: app.Application, + query: pipeline_query.Query, + response_content: str | None, +) -> PreparedSkillActivation | None: + """Prepare multi-skill activation state on the query.""" + if not response_content or not getattr(ap, 'skill_mgr', None): + return None + + activated_skill_names = ap.skill_mgr.detect_skill_activations(response_content) + if not activated_skill_names: + return None + + prompt = ap.skill_mgr.build_activation_prompt_for_skills(activated_skill_names) + if not prompt: + return None + + for skill_name in activated_skill_names: + skill_data = ap.skill_mgr.get_skill_by_name(skill_name) + if skill_data: + skill_loader.register_activated_skill(query, skill_data) + + return PreparedSkillActivation( + activated_skill_names=activated_skill_names, + cleaned_content=ap.skill_mgr.remove_activation_marker(response_content), + prompt=prompt, + ) + + +def get_skill_activation_coordinator(ap: app.Application) -> SkillActivationCoordinator | None: + skill_mgr = getattr(ap, 'skill_mgr', None) + if skill_mgr is None: + return None + + required_methods = ( + 'detect_skill_activations', + 'remove_activation_marker', + ) + if any(not hasattr(skill_mgr, method_name) for method_name in required_methods): + return None + + return SkillActivationCoordinator(ap, skill_mgr) diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py new file mode 100644 index 00000000..bf8b48e4 --- /dev/null +++ b/src/langbot/pkg/skill/manager.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import datetime as dt +import os +import re +import typing + +from ..core import app +from .utils import parse_frontmatter +from ..utils import paths + +if typing.TYPE_CHECKING: + pass + + +class SkillManager: + """Skill manager backed purely by filesystem packages under data/skills.""" + + SKILL_ACTIVATION_MARKER = '[ACTIVATE_SKILL:' + + ap: app.Application + skills: dict[str, dict] + + def __init__(self, ap: app.Application): + self.ap = ap + self.skills = {} + + async def initialize(self): + await self.reload_skills() + + async def reload_skills(self): + self.skills = {} + + skills_root = self.get_managed_skills_root() + if not os.path.isdir(skills_root): + self.ap.logger.info('Loaded 0 skills') + return + + for package_root, entry_file in self._discover_skill_directories(skills_root): + skill_data = { + 'package_root': package_root, + 'entry_file': entry_file, + } + if not self._load_skill_file(skill_data): + continue + + skill_name = skill_data['name'] + if skill_name in self.skills: + self.ap.logger.warning( + f'Duplicate skill name "{skill_name}" found at {package_root}, skipping later entry' + ) + continue + + self.skills[skill_name] = skill_data + + self.ap.logger.info(f'Loaded {len(self.skills)} skills') + + def refresh_skill_from_disk(self, skill_name: str) -> bool: + if not skill_name: + return False + + skill_data = self.skills.get(skill_name) + if not skill_data: + return False + + if not self._load_skill_file(skill_data): + return False + + self.skills[skill_name] = skill_data + return True + + @staticmethod + def get_managed_skills_root() -> str: + return paths.get_data_path('skills') + + def _discover_skill_directories(self, root_path: str, max_depth: int = 6) -> list[tuple[str, str]]: + discovered: list[tuple[str, str]] = [] + root_path = os.path.realpath(os.path.abspath(root_path)) + root_depth = root_path.rstrip(os.sep).count(os.sep) + + for current_root, dirs, _files in os.walk(root_path): + current_root = os.path.realpath(current_root) + depth = current_root.rstrip(os.sep).count(os.sep) - root_depth + if depth > max_depth: + dirs[:] = [] + continue + + found = self._find_skill_entry(current_root) + if found is not None: + discovered.append(found) + dirs[:] = [] + + discovered.sort(key=lambda item: item[0]) + return discovered + + @staticmethod + def _find_skill_entry(path: str) -> tuple[str, str] | None: + for candidate in ('SKILL.md', 'skill.md'): + if os.path.isfile(os.path.join(path, candidate)): + return path, candidate + return None + + def _load_skill_file(self, skill_data: dict) -> bool: + package_root = self._normalize_package_root(skill_data.get('package_root', '')) + entry_file = skill_data.get('entry_file', 'SKILL.md') + if not package_root: + self.ap.logger.warning('Skill package_root is empty, skipping') + return False + + entry_path = os.path.join(package_root, entry_file) + try: + with open(entry_path, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + self.ap.logger.warning(f'Skill entry file not found: {entry_path}, skipping') + return False + except OSError as exc: + self.ap.logger.warning(f'Failed to read skill entry file {entry_path}: {exc}, skipping') + return False + + metadata, instructions = parse_frontmatter(content) + name = str(metadata.get('name') or os.path.basename(os.path.normpath(package_root))).strip() + if not name: + self.ap.logger.warning(f'Skill at {package_root} has no valid name, skipping') + return False + + stat = os.stat(entry_path) + skill_data.clear() + skill_data.update( + { + 'name': name, + 'display_name': str(metadata.get('display_name') or name).strip(), + 'description': str(metadata.get('description') or '').strip(), + 'instructions': instructions, + 'raw_content': content, + 'package_root': package_root, + 'entry_file': entry_file, + 'auto_activate': bool(metadata.get('auto_activate', True)), + 'created_at': dt.datetime.fromtimestamp(stat.st_ctime, tz=dt.timezone.utc).isoformat(), + 'updated_at': dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc).isoformat(), + } + ) + return True + + @staticmethod + def _normalize_package_root(package_root: str) -> str: + if not package_root: + return '' + return os.path.realpath(os.path.abspath(package_root)) + + def get_skill_by_name(self, name: str) -> dict | None: + return self.skills.get(name) + + def get_skill_index(self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None) -> str: + skills_to_index = [] + for skill in self.skills.values(): + if not skill.get('auto_activate', True): + continue + if bound_skills is not None and skill['name'] not in bound_skills: + continue + skills_to_index.append(skill) + + if not skills_to_index: + return '' + + lines = ['Available Skills:'] + for skill in skills_to_index: + display = skill.get('display_name') or skill['name'] + lines.append(f'- {skill["name"]} ({display}): {skill.get("description", "")}') + return '\n'.join(lines) + + def build_skill_aware_prompt_addition( + self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None + ) -> str: + skill_index = self.get_skill_index(pipeline_uuid, bound_skills) + if not skill_index: + return '' + + return f""" + +{skill_index} + +When the user's request clearly matches one or more skills based on their descriptions, you should activate them. +To activate a skill, include this marker at the beginning of your response: [ACTIVATE_SKILL: skill-name] +If multiple skills are needed, include multiple activation markers at the beginning of your response, one per line. +After activation, the selected skills' detailed instructions will be loaded for you to follow. +Use the first activated skill as the primary skill. Use any additional activated skills as supporting guidance. +If you need to inspect a visible skill before activation, use `read` on `/workspace/.skills//SKILL.md` or other files under that path. +For prompt-only skills or skills that mainly consist of instructions, use `create_skill` to create them directly in the managed skills store. +Use `list_skills` or `get_skill` before editing when you need to inspect what already exists. +Use `update_skill` to modify an existing managed skill's metadata or instructions without relying on `/workspace`, box, or skill activation. +Use `delete_skill` when the user explicitly wants to remove a managed skill. +Pure prompt skills should not depend on box just to be created or modified later. +When creating a new skill package with extra files, scripts, or assets, first prepare it under `/workspace` with the standard `exec`, `read`, `write`, and `edit` tools. +Then use `import_skill_from_directory` to import that prepared directory into the managed skills store. +Use `reload_skills` when you need LangBot to rescan managed skills after filesystem changes. +If no skill matches, respond normally without activation. +""" + + def detect_skill_activations(self, response: str) -> list[str]: + if self.SKILL_ACTIVATION_MARKER not in response: + return [] + + activated: list[str] = [] + for skill_name in re.findall(r'\[ACTIVATE_SKILL:\s*(\S+?)\s*\]', response): + if skill_name in self.skills and skill_name not in activated: + activated.append(skill_name) + return activated + + def detect_skill_activation(self, response: str) -> str | None: + activations = self.detect_skill_activations(response) + return activations[0] if activations else None + + def get_skill_runtime_data(self, skill_name: str) -> dict | None: + skill = self.skills.get(skill_name) + if not skill: + return None + return {'skill': skill, 'instructions': skill.get('instructions', '')} + + def build_activation_prompt(self, skill_name: str) -> str: + resolved = self.get_skill_runtime_data(skill_name) + if not resolved: + return '' + + instructions = resolved['instructions'] + return f""" + + +## Instructions +{instructions} + +## Runtime Context +The activated skill package is available through the standard runtime tools under `/workspace/.skills/{skill_name}`. +Use `read` to inspect files there. Use `exec` with `workdir` set to `/workspace/.skills/{skill_name}` to run commands in that package. +Use `write` and `edit` on that path when the instructions require updating files. +Do not create a new skill by writing directly into `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `update_skill` to change an existing managed skill, `list_skills` or `get_skill` to inspect managed skills, or prepare the new skill under `/workspace` and import it with `import_skill_from_directory`. + + + +Now execute the above skill instructions step by step to complete the user's request. +Use the standard `exec`, `read`, `write`, and `edit` tools against `/workspace/.skills/{skill_name}` when you need to inspect or modify the skill package. +Respond to the user based on the skill's guidance. +""" + + def build_activation_prompt_for_skills(self, skill_names: list[str]) -> str: + if not skill_names: + return '' + + activated_skill_names: list[str] = [] + for skill_name in skill_names: + if skill_name in self.skills and skill_name not in activated_skill_names: + activated_skill_names.append(skill_name) + if not activated_skill_names: + return '' + + blocks: list[str] = [] + for skill_name in activated_skill_names: + resolved = self.get_skill_runtime_data(skill_name) + if not resolved: + continue + instructions = resolved['instructions'] + role = 'primary' if skill_name == activated_skill_names[0] else 'auxiliary' + blocks.append( + f""" +\n\n## Instructions\n{instructions}\n\n## Runtime Context\nUse the standard `exec`, `read`, `write`, and `edit` tools for activated skills.\nEach activated skill package is available under `/workspace/.skills/`.\nFor a given skill, set `exec.workdir` to `/workspace/.skills/` and use that prefix in file tool paths.\nDo not create a new skill under `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `list_skills` or `get_skill` to inspect managed skills, `update_skill` to change an existing managed skill, or prepare new skill directories under `/workspace` and import them with `import_skill_from_directory`.\n\n +""".strip() + ) + if not blocks: + return '' + + activated_list = ', '.join(activated_skill_names) + return f""" +Activated skills: {activated_list} + +{chr(10).join(blocks)} + +Now execute the activated skills to complete the user's request. +Treat the first activated skill as the primary skill. +Treat additional activated skills as supporting guidance when they do not conflict with the primary skill. +If guidance conflicts, prefer: primary skill > auxiliary skills. +Use the standard `exec`, `read`, `write`, and `edit` tools against the corresponding `/workspace/.skills/` path whenever you need to inspect or modify an activated skill package. +Respond to the user with one coherent answer that integrates the activated skills. +""" + + @staticmethod + def remove_activation_marker(response: str) -> str: + return re.sub(r'\[ACTIVATE_SKILL:\s*\S+?\s*\]\s*', '', response).lstrip() diff --git a/src/langbot/pkg/skill/utils.py b/src/langbot/pkg/skill/utils.py new file mode 100644 index 00000000..fc143362 --- /dev/null +++ b/src/langbot/pkg/skill/utils.py @@ -0,0 +1,37 @@ +"""Shared utilities for skill file parsing.""" + +import yaml + + +def parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from markdown content. + + Expects format: + --- + name: my-skill + description: Does something + --- + # Actual instructions... + + Returns: + Tuple of (metadata dict, remaining content) + """ + if not content.startswith('---'): + return {}, content + + parts = content.split('---', 2) + if len(parts) < 3: + return {}, content + + frontmatter_str = parts[1].strip() + instructions = parts[2].strip() + + try: + metadata = yaml.safe_load(frontmatter_str) or {} + except yaml.YAMLError: + metadata = {} + + if not isinstance(metadata, dict): + metadata = {} + + return metadata, instructions diff --git a/src/langbot/pkg/utils/paths.py b/src/langbot/pkg/utils/paths.py index fd052c50..6f95ec82 100644 --- a/src/langbot/pkg/utils/paths.py +++ b/src/langbot/pkg/utils/paths.py @@ -1,37 +1,70 @@ -"""Utility functions for finding package resources""" +"""Utility functions for finding package resources and runtime data roots.""" import os from pathlib import Path _is_source_install = None +_source_root = None + + +def _find_source_root() -> Path | None: + """Locate the LangBot repository root when running from source.""" + global _source_root + + if _source_root is not None: + return _source_root + + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / 'pyproject.toml').exists() and (parent / 'main.py').exists(): + _source_root = parent + return parent + + _source_root = None + return None def _check_if_source_install() -> bool: """ - Check if we're running from source directory or an installed package. - Cached to avoid repeated file I/O. + Check if we're running from the LangBot source tree. + Cached to avoid repeated filesystem scans. """ global _is_source_install if _is_source_install is not None: return _is_source_install - # Check if main.py exists in current directory with LangBot marker - if os.path.exists('main.py'): - try: - with open('main.py', 'r', encoding='utf-8') as f: - # Only read first 500 chars to check for marker - content = f.read(500) - if 'LangBot/main.py' in content: - _is_source_install = True - return True - except (IOError, OSError, UnicodeDecodeError): - # If we can't read the file, assume not a source install - pass + _is_source_install = _find_source_root() is not None + return _is_source_install - _is_source_install = False - return False + +def get_data_root() -> str: + """ + Get the runtime data root. + + Priority: + 1. LANGBOT_DATA_ROOT environment override + 2. Source checkout root /data when running from source + 3. Current working directory /data for installed-package usage + """ + env_root = os.environ.get('LANGBOT_DATA_ROOT', '').strip() + if env_root: + return str(Path(env_root).expanduser().resolve()) + + source_root = _find_source_root() + if source_root is not None: + return str((source_root / 'data').resolve()) + + return str((Path.cwd() / 'data').resolve()) + + +def get_data_path(*parts: str) -> str: + """Join path segments under the resolved data root.""" + data_root = Path(get_data_root()) + if not parts: + return str(data_root) + return str((data_root.joinpath(*parts)).resolve()) def get_frontend_path() -> str: @@ -76,8 +109,11 @@ def get_resource_path(resource: str) -> str: Absolute path to the resource """ # First, check if resource exists in current directory (source install) - if _check_if_source_install() and os.path.exists(resource): - return resource + source_root = _find_source_root() + if source_root is not None: + source_resource = source_root / resource + if source_resource.exists(): + return str(source_resource) # Second, check current directory anyway if os.path.exists(resource): diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 07c63df4..c8e927bb 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -93,6 +93,7 @@ box: shared_host_root: './data/box' # For Docker deployment, use '/workspaces' default_host_workspace: '' # Defaults to '/default' allowed_host_mount_roots: # Defaults to [''] when left empty + - './data/box' - '/tmp' space: # Space service URL for OAuth and API diff --git a/tests/unit_tests/provider/test_localagent_sandbox_exec.py b/tests/unit_tests/provider/test_localagent_sandbox_exec.py index f508d0d5..cd4507ae 100644 --- a/tests/unit_tests/provider/test_localagent_sandbox_exec.py +++ b/tests/unit_tests/provider/test_localagent_sandbox_exec.py @@ -91,6 +91,101 @@ class RecordingStreamProvider: return _stream() +class ActivationProvider: + def __init__(self): + self.requests: list[dict] = [] + + async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None): + self.requests.append( + { + 'messages': list(messages), + 'funcs': list(funcs), + 'remove_think': remove_think, + } + ) + if len(self.requests) == 1: + return provider_message.Message( + role='assistant', + content='[ACTIVATE_SKILL: demo]\nI will use the skill.', + ) + return provider_message.Message( + role='assistant', + content='final answer after activation', + ) + + +class FailingActivationProvider: + def __init__(self): + self.requests: list[dict] = [] + + async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None): + self.requests.append( + { + 'messages': list(messages), + 'funcs': list(funcs), + 'remove_think': remove_think, + } + ) + if len(self.requests) == 1: + return provider_message.Message( + role='assistant', + content='[ACTIVATE_SKILL: demo]\nI will use the skill.', + ) + raise RuntimeError('activation failed') + + +class ActivationStreamProvider: + def __init__(self): + self.stream_requests: list[dict] = [] + + def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None): + self.stream_requests.append( + { + 'messages': list(messages), + 'funcs': list(funcs), + 'remove_think': remove_think, + } + ) + + async def _stream(): + if len(self.stream_requests) == 1: + yield provider_message.MessageChunk( + role='assistant', + content='[ACTIVATE_SKILL: demo]\nI will use the skill.', + is_final=True, + ) + return + + yield provider_message.MessageChunk( + role='assistant', + content='final answer after activation', + is_final=True, + ) + + return _stream() + + +def make_skill_manager(): + skill_data = { + 'uuid': 'skill-demo', + 'name': 'demo', + 'instructions': 'Do the demo task.', + 'type': 'skill', + 'package_root': '/tmp/demo-skill', + 'sandbox_timeout_sec': 120, + 'sandbox_network': False, + } + return SimpleNamespace( + SKILL_ACTIVATION_MARKER='[ACTIVATE_SKILL:', + detect_skill_activations=Mock( + side_effect=lambda content: ['demo'] if '[ACTIVATE_SKILL: demo]' in (content or '') else [] + ), + build_activation_prompt_for_skills=Mock(return_value='skill prompt'), + get_skill_by_name=Mock(side_effect=lambda name: skill_data if name == 'demo' else None), + remove_activation_marker=Mock(side_effect=lambda content: (content or '').replace('[ACTIVATE_SKILL: demo]\n', '')), + ) + + def make_query() -> pipeline_query.Query: adapter = AsyncMock() adapter.is_stream_output_supported = AsyncMock(return_value=False) @@ -168,6 +263,11 @@ async def test_localagent_uses_exec_for_exact_calculation(): ) ), ), + skill_mgr=SimpleNamespace( + get_skills_for_pipeline=AsyncMock(return_value=[]), + detect_skill_activation=AsyncMock(return_value=None), + build_activation_prompt=Mock(return_value=None), + ), ) runner = LocalAgentRunner(app, pipeline_config={}) @@ -222,6 +322,11 @@ async def test_localagent_streaming_tool_error_yields_message_chunks(): box_service=SimpleNamespace( get_system_guidance=Mock(return_value='sandbox guidance'), ), + skill_mgr=SimpleNamespace( + get_skills_for_pipeline=AsyncMock(return_value=[]), + detect_skill_activation=AsyncMock(return_value=None), + build_activation_prompt=Mock(return_value=None), + ), ) runner = LocalAgentRunner(app, pipeline_config={}) @@ -230,3 +335,110 @@ async def test_localagent_streaming_tool_error_yields_message_chunks(): assert all(isinstance(message, provider_message.MessageChunk) for message in results) assert any(message.role == 'tool' and message.content == 'err: boom' for message in results) + + +@pytest.mark.asyncio +async def test_localagent_hides_activation_marker_before_follow_up_request(): + provider = ActivationProvider() + model = SimpleNamespace( + provider=provider, + model_entity=SimpleNamespace( + uuid='test-model-uuid', + name='test-model', + abilities=['func_call'], + extra_args={}, + ), + ) + + app = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()), + rag_mgr=SimpleNamespace(), + box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')), + skill_mgr=make_skill_manager(), + ) + + runner = LocalAgentRunner(app, pipeline_config={}) + query = make_query() + query.use_funcs = [] + + results = [message async for message in runner.run(query)] + + assert [(message.role, message.content) for message in results] == [ + ('assistant', 'final answer after activation') + ] + assert len(provider.requests) == 2 + assert provider.requests[1]['messages'][-2].content == 'I will use the skill.' + assert '[ACTIVATE_SKILL:' not in provider.requests[1]['messages'][-2].content + + +@pytest.mark.asyncio +async def test_localagent_activation_failure_rolls_back_query_state_and_sanitizes_response(): + provider = FailingActivationProvider() + model = SimpleNamespace( + provider=provider, + model_entity=SimpleNamespace( + uuid='test-model-uuid', + name='test-model', + abilities=['func_call'], + extra_args={}, + ), + ) + + app = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()), + rag_mgr=SimpleNamespace(), + box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')), + skill_mgr=make_skill_manager(), + ) + + runner = LocalAgentRunner(app, pipeline_config={}) + query = make_query() + query.use_funcs = [] + + results = [message async for message in runner.run(query)] + + assert [(message.role, message.content) for message in results] == [ + ('assistant', 'I will use the skill.') + ] + assert query.use_funcs == [] + assert query.variables == {} + + +@pytest.mark.asyncio +async def test_localagent_streaming_activation_does_not_leak_marker(): + provider = ActivationStreamProvider() + model = SimpleNamespace( + provider=provider, + model_entity=SimpleNamespace( + uuid='test-model-uuid', + name='test-model', + abilities=['func_call'], + extra_args={}, + ), + ) + + adapter = AsyncMock() + adapter.is_stream_output_supported = AsyncMock(return_value=True) + + app = SimpleNamespace( + logger=Mock(), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()), + rag_mgr=SimpleNamespace(), + box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')), + skill_mgr=make_skill_manager(), + ) + + runner = LocalAgentRunner(app, pipeline_config={}) + query = make_query() + query.adapter = adapter + query.use_funcs = [] + + results = [message async for message in runner.run(query)] + + assert all(isinstance(message, provider_message.MessageChunk) for message in results) + assert [message.content for message in results] == ['final answer after activation'] diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py new file mode 100644 index 00000000..315c55ed --- /dev/null +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +import os +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + + +def _make_ap(logger=None): + ap = SimpleNamespace() + ap.logger = logger or Mock() + ap.persistence_mgr = Mock() + ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[]))) + ap.persistence_mgr.serialize_model = Mock(side_effect=lambda cls, row: row) + return ap + + +def _make_skill_data( + name='test-skill', + instructions='Do something', + package_root='', + entry_file='SKILL.md', + auto_activate=True, + **kwargs, +): + return { + 'name': name, + 'display_name': kwargs.pop('display_name', name), + 'description': kwargs.pop('description', f'Description of {name}'), + 'instructions': instructions, + 'package_root': package_root, + 'entry_file': entry_file, + 'auto_activate': auto_activate, + **kwargs, + } + + +class TestSkillManagerPackageLoading: + def test_load_skill_file_success(self): + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + + with tempfile.TemporaryDirectory() as tmpdir: + skill_md = os.path.join(tmpdir, 'SKILL.md') + with open(skill_md, 'w', encoding='utf-8') as f: + f.write('---\ndescription: Test skill\n---\n\n# Test Skill\nDo things.') + + skill_data = _make_skill_data(package_root=tmpdir) + result = mgr._load_skill_file(skill_data) + + assert result is True + assert skill_data['instructions'] == '# Test Skill\nDo things.' + assert skill_data['description'] == 'Test skill' + + def test_refresh_skill_from_disk_updates_cached_dict_in_place(self): + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + + with tempfile.TemporaryDirectory() as tmpdir: + skill_md = os.path.join(tmpdir, 'SKILL.md') + with open(skill_md, 'w', encoding='utf-8') as f: + f.write('---\ndescription: First\n---\n\nOriginal instructions') + + skill_data = _make_skill_data(name='test-skill', package_root=tmpdir) + assert mgr._load_skill_file(skill_data) is True + + mgr.skills['test-skill'] = skill_data + + with open(skill_md, 'w', encoding='utf-8') as f: + f.write('---\ndescription: Second\n---\n\nUpdated instructions') + + assert mgr.refresh_skill_from_disk('test-skill') is True + assert mgr.skills['test-skill'] is skill_data + assert skill_data['instructions'] == 'Updated instructions' + assert skill_data['description'] == 'Second' + + +class TestSkillManagerActivation: + def test_detect_skill_activations_returns_unique_ordered_skills(self): + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + mgr.skills = { + 'alpha': _make_skill_data(name='alpha'), + 'beta': _make_skill_data(name='beta'), + } + + response = ( + '[ACTIVATE_SKILL: alpha]\n' + '[ACTIVATE_SKILL: beta]\n' + '[ACTIVATE_SKILL: alpha]\n' + 'Let me handle this.' + ) + + assert mgr.detect_skill_activations(response) == ['alpha', 'beta'] + assert mgr.detect_skill_activation(response) == 'alpha' + + def test_build_activation_prompt_for_skills_includes_runtime_guidance(self): + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + mgr.skills = { + 'primary': _make_skill_data(name='primary', instructions='Primary instructions'), + 'aux': _make_skill_data(name='aux', instructions='Aux instructions'), + } + + prompt = mgr.build_activation_prompt_for_skills(['primary', 'aux']) + + assert 'Activated skills: primary, aux' in prompt + assert 'role="primary"' in prompt + assert 'role="auxiliary"' in prompt + assert '/workspace/.skills/' in prompt + + def test_remove_activation_marker_removes_multiple_markers(self): + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + + response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\nFinal answer' + assert mgr.remove_activation_marker(response) == 'Final answer' + + +class TestSkillActivationHelper: + def test_prepare_skill_activation_registers_only_explicit_activated_skills(self): + from langbot.pkg.skill.activation import prepare_skill_activation + from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY + from langbot.pkg.skill.manager import SkillManager + + ap = _make_ap() + mgr = SkillManager(ap) + mgr.skills = { + 'primary': _make_skill_data(name='primary', instructions='Primary instructions'), + 'aux': _make_skill_data(name='aux', instructions='Aux instructions'), + } + ap.skill_mgr = mgr + + query = SimpleNamespace(variables={}, use_funcs=[]) + activation = prepare_skill_activation( + ap, + query, + '[ACTIVATE_SKILL: primary]\n[ACTIVATE_SKILL: aux]\nWorking on it.', + ) + + assert activation is not None + assert activation.activated_skill_names == ['primary', 'aux'] + assert activation.cleaned_content == 'Working on it.' + assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary', 'aux'} + + +class TestSkillPathHelpers: + def test_get_visible_skills_filters_by_bound_names(self): + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace( + skills={ + 'visible': _make_skill_data(name='visible'), + 'hidden': _make_skill_data(name='hidden'), + } + ) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']}) + + result = get_visible_skills(ap, query) + + assert list(result.keys()) == ['visible'] + + def test_resolve_virtual_skill_path_allows_visible_skill_reads(self): + from langbot.pkg.provider.tools.loaders.skill import ( + PIPELINE_BOUND_SKILLS_KEY, + resolve_virtual_skill_path, + ) + + ap = _make_ap() + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')}) + query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}) + + skill, rewritten = resolve_virtual_skill_path( + ap, + query, + '/workspace/.skills/demo/SKILL.md', + include_visible=True, + include_activated=False, + ) + + assert skill['name'] == 'demo' + assert rewritten == '/workspace/SKILL.md' + + def test_build_skill_session_id_uses_name_based_identifier(self): + from langbot.pkg.provider.tools.loaders.skill import build_skill_session_id + + with_launcher = build_skill_session_id( + {'name': 'writer'}, + SimpleNamespace(query_id=42, launcher_type='person', launcher_id='123'), + ) + fallback = build_skill_session_id({'name': 'writer'}, SimpleNamespace(query_id=99)) + + assert with_launcher == 'skill-person_123-writer' + assert fallback == 'skill-99-writer' + + def test_should_prepare_skill_python_env_detects_manifests_and_venv(self): + from langbot.pkg.provider.tools.loaders.skill import should_prepare_skill_python_env + + with tempfile.TemporaryDirectory() as tmpdir: + assert should_prepare_skill_python_env(tmpdir) is False + + with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as f: + f.write('requests==2.32.0\n') + assert should_prepare_skill_python_env(tmpdir) is True + + with tempfile.TemporaryDirectory() as tmpdir: + os.makedirs(os.path.join(tmpdir, '.venv')) + assert should_prepare_skill_python_env(tmpdir) is True + + def test_wrap_skill_command_with_python_env_bootstraps_then_runs_command(self): + from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env + + command = wrap_skill_command_with_python_env('python scripts/run.py') + + assert 'python -m venv "$_LB_VENV_DIR"' in command + assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command + assert command.rstrip().endswith('python scripts/run.py') + + +class TestSkillAuthoringToolLoader: + @pytest.mark.asyncio + async def test_create_skill_creates_managed_prompt_only_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + CREATE_SKILL_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')), + reload_skills=AsyncMock(), + list_skills=AsyncMock(return_value=[]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool( + CREATE_SKILL_TOOL_NAME, + { + 'name': 'prompt-skill', + 'display_name': 'Prompt Skill', + 'description': 'Prompt only skill', + 'instructions': 'Follow these steps carefully.', + 'auto_activate': False, + }, + SimpleNamespace(), + ) + + ap.skill_service.create_skill.assert_awaited_once_with( + { + 'name': 'prompt-skill', + 'display_name': 'Prompt Skill', + 'description': 'Prompt only skill', + 'instructions': 'Follow these steps carefully.', + 'auto_activate': False, + } + ) + assert result == { + 'created': True, + 'skill': _make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill'), + } + + @pytest.mark.asyncio + async def test_list_skills_returns_managed_skills(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + LIST_SKILLS_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool(LIST_SKILLS_TOOL_NAME, {}, SimpleNamespace()) + + assert result == { + 'skills': [_make_skill_data(name='alpha'), _make_skill_data(name='beta')], + 'skill_names': ['alpha', 'beta'], + 'count': 2, + } + + @pytest.mark.asyncio + async def test_get_skill_returns_one_managed_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + GET_SKILL_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + get_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool(GET_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace()) + + ap.skill_service.get_skill.assert_awaited_once_with('time-now') + assert result == { + 'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'), + } + + @pytest.mark.asyncio + async def test_update_skill_updates_managed_prompt_only_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + UPDATE_SKILL_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + create_skill=AsyncMock(), + update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')), + reload_skills=AsyncMock(), + list_skills=AsyncMock(return_value=[]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool( + UPDATE_SKILL_TOOL_NAME, + { + 'name': 'time-now', + 'description': 'Fixed to Beijing time', + 'instructions': 'Always use Asia/Shanghai and never offer other timezones.', + 'auto_activate': True, + }, + SimpleNamespace(), + ) + + ap.skill_service.update_skill.assert_awaited_once_with( + 'time-now', + { + 'name': 'time-now', + 'description': 'Fixed to Beijing time', + 'instructions': 'Always use Asia/Shanghai and never offer other timezones.', + 'auto_activate': True, + }, + ) + assert result == { + 'updated': True, + 'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'), + } + + @pytest.mark.asyncio + async def test_delete_skill_deletes_managed_skill(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + DELETE_SKILL_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + delete_skill=AsyncMock(return_value=True), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool(DELETE_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace()) + + ap.skill_service.delete_skill.assert_awaited_once_with('time-now') + assert result == { + 'deleted': True, + 'skill_name': 'time-now', + } + + @pytest.mark.asyncio + async def test_import_skill_from_directory_uses_workspace_path_and_service_import(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') + ap.skill_service = SimpleNamespace( + scan_directory=Mock( + return_value={ + 'name': 'cloned-skill', + 'display_name': 'Cloned Skill', + 'description': 'Imported from clone', + 'instructions': 'Do work', + 'auto_activate': True, + } + ), + create_skill=AsyncMock(return_value=_make_skill_data(name='cloned-skill', package_root='/repo/root')), + reload_skills=AsyncMock(), + list_skills=AsyncMock(return_value=[]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + with tempfile.TemporaryDirectory() as tmpdir: + ap.box_service.default_host_workspace = tmpdir + repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill') + os.makedirs(repo_dir) + + result = await loader.invoke_tool( + IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + {'path': '/workspace/repos/cloned-skill'}, + SimpleNamespace(), + ) + + ap.skill_service.scan_directory.assert_called_once_with(os.path.realpath(repo_dir)) + ap.skill_service.create_skill.assert_awaited_once_with( + { + 'name': 'cloned-skill', + 'display_name': 'Cloned Skill', + 'description': 'Imported from clone', + 'instructions': 'Do work', + 'package_root': os.path.realpath(repo_dir), + 'auto_activate': True, + } + ) + assert result['imported'] is True + assert result['source_path'] == '/workspace/repos/cloned-skill' + + @pytest.mark.asyncio + async def test_import_skill_from_directory_rejects_workspace_escape(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace') + ap.skill_service = SimpleNamespace( + scan_directory=Mock(), + create_skill=AsyncMock(), + reload_skills=AsyncMock(), + list_skills=AsyncMock(return_value=[]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + with pytest.raises(ValueError, match='escapes the workspace boundary'): + await loader.invoke_tool( + IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME, + {'path': '/workspace/../../etc'}, + SimpleNamespace(), + ) + + @pytest.mark.asyncio + async def test_reload_skills_rescans_filesystem_and_returns_current_names(self): + from langbot.pkg.provider.tools.loaders.skill_authoring import ( + RELOAD_SKILLS_TOOL_NAME, + SkillAuthoringToolLoader, + ) + + ap = _make_ap() + ap.skill_service = SimpleNamespace( + reload_skills=AsyncMock(), + list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]), + ) + + loader = SkillAuthoringToolLoader(ap) + await loader.initialize() + + result = await loader.invoke_tool(RELOAD_SKILLS_TOOL_NAME, {}, SimpleNamespace()) + + assert result == { + 'reloaded': True, + 'skill_names': ['alpha', 'beta'], + 'count': 2, + } + ap.skill_service.reload_skills.assert_awaited_once_with() + + +class TestNativeToolLoaderSkillPaths: + @pytest.mark.asyncio + async def test_read_visible_skill_file(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + + with tempfile.TemporaryDirectory() as tmpdir: + skill_md = os.path.join(tmpdir, 'SKILL.md') + with open(skill_md, 'w', encoding='utf-8') as f: + f.write('demo instructions') + + ap = _make_ap() + ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) + loader = NativeToolLoader(ap) + + result = await loader.invoke_tool( + 'read', + {'path': '/workspace/.skills/demo/SKILL.md'}, + SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}), + ) + + assert result == {'ok': True, 'content': 'demo instructions'} + + @pytest.mark.asyncio + async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import register_activated_skill + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() + ap.box_service = SimpleNamespace( + available=True, + default_host_workspace=tmpdir, + execute_spec_payload=AsyncMock(return_value={'ok': True}), + ) + ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock()) + loader = NativeToolLoader(ap) + + query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={}) + register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir)) + + result = await loader.invoke_tool( + 'exec', + { + 'command': 'python /workspace/.skills/demo/scripts/run.py', + 'workdir': '/workspace/.skills/demo', + }, + query, + ) + + assert result == {'ok': True} + spec_payload = ap.box_service.execute_spec_payload.await_args.args[0] + assert spec_payload['cmd'] == 'python /workspace/scripts/run.py' + assert spec_payload['workdir'] == '/workspace' + assert spec_payload['host_path'] == tmpdir + assert spec_payload['session_id'] == 'skill-person_123-demo' + ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo') + + @pytest.mark.asyncio + async def test_write_requires_skill_activation(self): + from langbot.pkg.provider.tools.loaders.native import NativeToolLoader + from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY + + with tempfile.TemporaryDirectory() as tmpdir: + ap = _make_ap() + ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir) + ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)}) + loader = NativeToolLoader(ap) + + query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}) + + with pytest.raises(ValueError, match='Skill "demo" is not available at this path'): + await loader.invoke_tool( + 'write', + {'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'}, + query, + ) diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index d08dad8b..27f6f47d 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -42,9 +42,10 @@ def make_tool(name: str) -> resource_tool.LLMTool: @pytest.mark.asyncio -async def test_tool_manager_lists_native_tools_first(): +async def test_tool_manager_omits_skill_authoring_tools_by_default(): manager = ToolManager(SimpleNamespace()) manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')]) manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) @@ -53,11 +54,25 @@ async def test_tool_manager_lists_native_tools_first(): assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool'] +@pytest.mark.asyncio +async def test_tool_manager_includes_skill_authoring_tools_when_requested(): + manager = ToolManager(SimpleNamespace()) + manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')]) + manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + tools = await manager.get_all_tools(include_skill_authoring=True) + + assert [tool.name for tool in tools] == ['exec', 'reload_skills', 'plugin_tool', 'mcp_tool'] + + @pytest.mark.asyncio async def test_tool_manager_routes_native_tool_calls(): app = SimpleNamespace() manager = ToolManager(app) manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'}) + manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')]) manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')]) manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) diff --git a/tests/unit_tests/test_paths.py b/tests/unit_tests/test_paths.py new file mode 100644 index 00000000..c1e84f44 --- /dev/null +++ b/tests/unit_tests/test_paths.py @@ -0,0 +1,23 @@ +from pathlib import Path + +from src.langbot.pkg.utils import paths + + +def test_get_data_root_uses_source_root_in_repo_checkout(): + data_root = Path(paths.get_data_root()) + repo_root = Path(__file__).resolve().parents[2] + + assert data_root == repo_root / 'data' + + +def test_get_data_path_joins_under_data_root(): + data_path = Path(paths.get_data_path('skills', 'demo-skill')) + repo_root = Path(__file__).resolve().parents[2] + + assert data_path == repo_root / 'data' / 'skills' / 'demo-skill' + + +def test_get_data_root_honors_env_override(monkeypatch, tmp_path): + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'custom-data')) + + assert Path(paths.get_data_root()) == (tmp_path / 'custom-data').resolve() diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py new file mode 100644 index 00000000..a5d411d3 --- /dev/null +++ b/tests/unit_tests/test_preproc.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import importlib +import sys +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from langbot_plugin.api.entities.builtin.pipeline.query import Query +from langbot_plugin.api.entities.builtin.platform.entities import Friend +from langbot_plugin.api.entities.builtin.platform.events import FriendMessage +from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain +from langbot_plugin.api.entities.builtin.provider.message import Message +from langbot_plugin.api.entities.builtin.provider.prompt import Prompt +from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session + + +def _make_query() -> Query: + message_chain = MessageChain([Plain(text='create a skill')]) + return Query( + query_id=1, + launcher_type=LauncherTypes.PERSON, + launcher_id='launcher-1', + sender_id='sender-1', + message_event=FriendMessage( + message_chain=message_chain, + time=0, + sender=Friend(id='sender-1', nickname='Tester', remark='Tester'), + ), + message_chain=message_chain, + bot_uuid='bot-1', + pipeline_uuid='pipe-1', + pipeline_config={ + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'model-1', 'fallbacks': []}, + 'prompt': 'default', + 'knowledge-bases': [], + }, + }, + 'trigger': {'misc': {}}, + }, + variables={}, + ) + + +def _make_conversation() -> Conversation: + return Conversation( + prompt=Prompt(name='default', messages=[Message(role='system', content='system prompt')]), + messages=[], + pipeline_uuid='pipe-1', + bot_uuid='bot-1', + uuid='conv-1', + ) + + +def _make_app(*, skill_service) -> SimpleNamespace: + session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1') + conversation = _make_conversation() + model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'})) + tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[])) + + return SimpleNamespace( + sess_mgr=SimpleNamespace( + get_session=AsyncMock(return_value=session), + get_conversation=AsyncMock(return_value=conversation), + ), + model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)), + tool_mgr=tool_mgr, + plugin_connector=SimpleNamespace( + emit_event=AsyncMock( + return_value=SimpleNamespace( + event=SimpleNamespace( + default_prompt=conversation.prompt.messages.copy(), + prompt=conversation.messages.copy(), + ) + ) + ) + ), + pipeline_service=SimpleNamespace( + get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}}) + ), + skill_mgr=SimpleNamespace( + build_skill_aware_prompt_addition=Mock(return_value=''), + skills={}, + ), + skill_service=skill_service, + logger=Mock(), + ) + + +def _import_preproc_modules(): + fake_app_module = types.ModuleType('langbot.pkg.core.app') + fake_app_module.Application = object + sys.modules['langbot.pkg.core.app'] = fake_app_module + + for module_name in ( + 'langbot.pkg.pipeline.preproc.preproc', + 'langbot.pkg.pipeline.stage', + ): + sys.modules.pop(module_name, None) + + preproc_module = importlib.import_module('langbot.pkg.pipeline.preproc.preproc') + entities_module = importlib.import_module('langbot.pkg.pipeline.entities') + return preproc_module, entities_module + + +@pytest.mark.asyncio +async def test_preproc_enables_skill_authoring_tools_when_skill_service_available(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + stage = preproc_module.PreProcessor(app) + + result = await stage.process(_make_query(), 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True) + + +@pytest.mark.asyncio +async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=None) + stage = preproc_module.PreProcessor(app) + + result = await stage.process(_make_query(), 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False) diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py new file mode 100644 index 00000000..acba02bd --- /dev/null +++ b/tests/unit_tests/test_skill_service.py @@ -0,0 +1,408 @@ +import io +import os +import zipfile +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from src.langbot.pkg.api.http.service.skill import SkillService + + +def _create_skill_file( + path, + *, + name: str = 'imported-skill', + display_name: str = '', + description: str = 'Imported from local directory', + auto_activate: bool = True, + body: str = 'Skill instructions', +) -> None: + frontmatter = ['name: ' + name, 'description: ' + description] + if display_name: + frontmatter.insert(1, 'display_name: ' + display_name) + if not auto_activate: + frontmatter.append('auto_activate: false') + + path.write_text( + '---\n' + '\n'.join(frontmatter) + f'\n---\n\n{body}\n', + encoding='utf-8', + ) + + +@pytest.fixture +def skill_service(): + app = SimpleNamespace( + skill_mgr=SimpleNamespace( + refresh_skill_from_disk=lambda *_args, **_kwargs: True, + reload_skills=AsyncMock(), + ) + ) + return SkillService(app) + + +def test_scan_directory_supports_nested_skill_within_two_levels(skill_service, tmp_path): + nested_dir = tmp_path / 'downloaded' / 'self-improving-agent' + nested_dir.mkdir(parents=True) + _create_skill_file(nested_dir / 'SKILL.md') + + result = skill_service.scan_directory(str(tmp_path)) + + assert result['package_root'] == str(nested_dir.resolve()) + assert result['entry_file'] == 'SKILL.md' + assert result['name'] == 'imported-skill' + assert result['instructions'] == 'Skill instructions' + + +def test_scan_directory_rejects_ambiguous_nested_skill_directories(skill_service, tmp_path): + first_dir = tmp_path / 'skills' / 'alpha' + second_dir = tmp_path / 'skills' / 'beta' + first_dir.mkdir(parents=True) + second_dir.mkdir(parents=True) + _create_skill_file(first_dir / 'SKILL.md', body='alpha instructions') + _create_skill_file(second_dir / 'SKILL.md', body='beta instructions') + + with pytest.raises(ValueError, match='Multiple skill directories found'): + skill_service.scan_directory(str(tmp_path)) + + +def test_scan_directory_errors_when_skill_is_deeper_than_two_levels(skill_service, tmp_path): + deep_dir = tmp_path / 'a' / 'b' / 'c' + deep_dir.mkdir(parents=True) + _create_skill_file(deep_dir / 'SKILL.md') + + with pytest.raises(ValueError, match='max depth: 2'): + skill_service.scan_directory(str(tmp_path)) + + +@pytest.mark.asyncio +async def test_create_skill_import_preserves_existing_skill_content_when_form_fields_blank(tmp_path, monkeypatch): + source_dir = tmp_path / 'external-skills' / 'manual-skill' + source_dir.mkdir(parents=True) + _create_skill_file( + source_dir / 'SKILL.md', + display_name='Imported Skill', + description='Imported description', + auto_activate=False, + body='Original instructions', + ) + + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + service.get_skill_by_name = AsyncMock(return_value=None) + managed_root = tmp_path / 'data' / 'skills' / 'imported-skill' + service.get_skill = AsyncMock( + return_value={ + 'name': 'imported-skill', + 'package_root': str(managed_root.resolve()), + 'description': 'Imported description', + 'instructions': 'Original instructions', + 'auto_activate': False, + } + ) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + + await service.create_skill( + { + 'name': 'imported-skill', + 'package_root': str(source_dir), + 'display_name': '', + 'description': '', + 'instructions': '', + } + ) + + content = (managed_root / 'SKILL.md').read_text(encoding='utf-8') + assert 'display_name: Imported Skill' in content + assert 'description: Imported description' in content + assert 'auto_activate: false' in content + assert content.endswith('Original instructions') + + +@pytest.mark.asyncio +async def test_create_skill_reuses_existing_managed_directory_without_copying(tmp_path, monkeypatch): + managed_root = tmp_path / 'data' / 'skills' / 'demo-repo' / 'skills' / 'nested-skill' + managed_root.mkdir(parents=True) + _create_skill_file( + managed_root / 'SKILL.md', + name='nested-skill', + display_name='Nested Skill', + description='Already managed', + body='Managed instructions', + ) + + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + service.get_skill_by_name = AsyncMock(return_value=None) + service.get_skill = AsyncMock( + return_value={ + 'name': 'nested-skill', + 'package_root': str(managed_root.resolve()), + 'description': 'Already managed', + 'instructions': 'Managed instructions', + 'auto_activate': True, + } + ) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + + await service.create_skill( + { + 'name': 'nested-skill', + 'package_root': str(managed_root), + 'display_name': '', + 'description': '', + 'instructions': '', + } + ) + + copied_root = tmp_path / 'data' / 'skills' / 'nested-skill' + assert not copied_root.exists() + content = (managed_root / 'SKILL.md').read_text(encoding='utf-8') + assert 'display_name: Nested Skill' in content + assert content.endswith('Managed instructions') + + +def _build_skill_archive() -> bytes: + stream = io.BytesIO() + with zipfile.ZipFile(stream, 'w') as archive: + archive.writestr( + 'demo-repo-main/skills/nested-skill/SKILL.md', + '---\n' + 'name: imported-skill\n' + 'description: Imported from GitHub archive\n' + '---\n\n' + 'Skill instructions\n', + ) + return stream.getvalue() + + +@pytest.mark.asyncio +async def test_install_from_github_supports_nested_skill_archive(skill_service, tmp_path, monkeypatch): + archive_bytes = _build_skill_archive() + + class _FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + + def raise_for_status(self) -> None: + return None + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str) -> _FakeResponse: + return _FakeResponse(archive_bytes) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient) + skill_service.get_skill = AsyncMock(return_value=None) + + result = await skill_service.install_from_github( + { + 'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main', + 'owner': 'example', + 'repo': 'demo-repo', + 'release_tag': 'main', + } + ) + + expected_root = tmp_path / 'data' / 'skills' / 'demo-repo-nested-skill-main' + assert result[0]['package_root'] == str(expected_root.resolve()) + assert (expected_root / 'SKILL.md').read_text(encoding='utf-8').endswith('Skill instructions\n') + + +@pytest.mark.asyncio +async def test_install_from_github_rejects_asset_url_outside_requested_repo(skill_service, tmp_path, monkeypatch): + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + + with pytest.raises(ValueError, match='owner/repo'): + await skill_service.install_from_github( + { + 'asset_url': 'https://api.github.com/repos/example/other-repo/zipball/main', + 'owner': 'example', + 'repo': 'demo-repo', + 'release_tag': 'main', + } + ) + + +@pytest.mark.asyncio +async def test_install_from_github_rejects_zip_with_path_traversal(skill_service, tmp_path, monkeypatch): + stream = io.BytesIO() + with zipfile.ZipFile(stream, 'w') as archive: + archive.writestr('../escape.txt', 'boom') + archive_bytes = stream.getvalue() + + class _FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + + def raise_for_status(self) -> None: + return None + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url: str) -> _FakeResponse: + return _FakeResponse(archive_bytes) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient) + + with pytest.raises(ValueError, match='unsafe path'): + await skill_service.install_from_github( + { + 'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main', + 'owner': 'example', + 'repo': 'demo-repo', + 'release_tag': 'main', + } + ) + + +@pytest.mark.asyncio +async def test_skill_file_operations_stay_within_package_root(skill_service, tmp_path): + skill_dir = tmp_path / 'mood-logger' + skill_dir.mkdir() + _create_skill_file(skill_dir / 'SKILL.md') + (skill_dir / 'resources').mkdir() + (skill_dir / 'resources' / 'keywords_zh.json').write_text('{"hello": 1}\n', encoding='utf-8') + + skill_record = { + 'name': 'mood-logger', + 'package_root': str(skill_dir), + 'entry_file': 'SKILL.md', + } + skill_service.get_skill = AsyncMock(return_value=skill_record) + + listed = await skill_service.list_skill_files('mood-logger', path='resources') + assert listed['entries'] == [ + { + 'path': 'resources/keywords_zh.json', + 'name': 'keywords_zh.json', + 'is_dir': False, + 'size': os.path.getsize(skill_dir / 'resources' / 'keywords_zh.json'), + } + ] + + read_back = await skill_service.read_skill_file('mood-logger', 'resources/keywords_zh.json') + assert read_back['content'] == '{"hello": 1}\n' + + written = await skill_service.write_skill_file('mood-logger', 'resources/affinity.py', 'print("ok")\n') + assert written['path'] == 'resources/affinity.py' + assert (skill_dir / 'resources' / 'affinity.py').read_text(encoding='utf-8') == 'print("ok")\n' + + +@pytest.mark.asyncio +async def test_skill_file_operations_reject_path_traversal(skill_service, tmp_path): + skill_dir = tmp_path / 'mood-logger' + skill_dir.mkdir() + _create_skill_file(skill_dir / 'SKILL.md') + + skill_service.get_skill = AsyncMock( + return_value={ + 'name': 'mood-logger', + 'package_root': str(skill_dir), + 'entry_file': 'SKILL.md', + } + ) + + with pytest.raises(ValueError, match='path must stay within the skill package root'): + await skill_service.read_skill_file('mood-logger', '../outside.txt') + + +@pytest.mark.asyncio +async def test_update_skill_rejects_package_root_change(tmp_path): + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + skill_root = tmp_path / 'data' / 'skills' / 'writer' + service.get_skill = AsyncMock( + return_value={ + 'name': 'writer', + 'package_root': str(skill_root.resolve()), + 'display_name': 'Writer', + 'description': 'Writes things', + 'instructions': 'Do work', + 'auto_activate': True, + } + ) + + with pytest.raises(ValueError, match='Updating package_root is not supported'): + await service.update_skill('writer', {'package_root': str(tmp_path / 'other-root')}) + + +@pytest.mark.asyncio +async def test_delete_skill_removes_managed_skill_directory(tmp_path, monkeypatch): + managed_root = tmp_path / 'data' / 'skills' / 'self-improving-agent' + managed_root.mkdir(parents=True) + _create_skill_file(managed_root / 'SKILL.md') + + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + service.get_skill = AsyncMock( + return_value={ + 'name': 'self-improving-agent', + 'package_root': str(managed_root.resolve()), + } + ) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + + result = await service.delete_skill('self-improving-agent') + + assert result is True + assert not managed_root.exists() + + +@pytest.mark.asyncio +async def test_delete_skill_removes_managed_install_root_for_nested_package(tmp_path, monkeypatch): + install_root = tmp_path / 'data' / 'skills' / 'demo-repo' + package_root = install_root / 'skills' / 'nested-skill' + package_root.mkdir(parents=True) + _create_skill_file(package_root / 'SKILL.md') + + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + service.get_skill = AsyncMock( + return_value={ + 'name': 'nested-skill', + 'package_root': str(package_root.resolve()), + } + ) + + monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data')) + + await service.delete_skill('nested-skill') + + assert not install_root.exists() + + +@pytest.mark.asyncio +async def test_delete_skill_rejects_external_package_directory(tmp_path, monkeypatch): + external_root = tmp_path / 'external-skills' / 'manual-skill' + external_root.mkdir(parents=True) + _create_skill_file(external_root / 'SKILL.md') + + service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock()))) + service.get_skill = AsyncMock( + return_value={ + 'name': 'manual-skill', + 'package_root': str(external_root.resolve()), + } + ) + + monkeypatch.chdir(tmp_path) + + with pytest.raises(ValueError, match='Only managed skills under data/skills'): + await service.delete_skill('manual-skill') diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 88fcc137..02c63a32 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList'; @@ -26,6 +26,7 @@ import { Store, Github, Zap, + FilePlus2, } from 'lucide-react'; import { useTheme } from '@/components/providers/theme-provider'; @@ -119,6 +120,7 @@ const ENTITY_CATEGORY_IDS = [ 'knowledge', 'plugins', 'mcp', + 'skills', ] as const; type EntityCategoryId = (typeof ENTITY_CATEGORY_IDS)[number]; @@ -129,6 +131,7 @@ const DETAIL_PAGE_CATEGORIES: EntityCategoryId[] = [ 'knowledge', 'plugins', 'mcp', + 'skills', ]; // Categories that support creating new entities from the sidebar @@ -138,6 +141,7 @@ const CREATABLE_CATEGORIES: EntityCategoryId[] = [ 'knowledge', 'mcp', 'plugins', + 'skills', ]; // Categories where clicking the parent only toggles collapse (no list page) @@ -146,6 +150,7 @@ const COLLAPSIBLE_ONLY_CATEGORIES: EntityCategoryId[] = [ 'pipelines', 'knowledge', 'mcp', + 'skills', ]; function isEntityCategory(id: string): id is EntityCategoryId { @@ -155,13 +160,14 @@ function isEntityCategory(id: string): id is EntityCategoryId { // Map sidebar config IDs to SidebarDataContext keys const ENTITY_KEY_MAP: Record< EntityCategoryId, - 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' + 'bots' | 'pipelines' | 'knowledgeBases' | 'plugins' | 'mcpServers' | 'skills' > = { bots: 'bots', pipelines: 'pipelines', knowledge: 'knowledgeBases', plugins: 'plugins', mcp: 'mcpServers', + skills: 'skills', }; // Route prefix map for entity detail pages @@ -171,6 +177,7 @@ const ENTITY_ROUTE_MAP: Record = { knowledge: '/home/knowledge', plugins: '/home/plugins', mcp: '/home/mcp', + skills: '/home/skills', }; // localStorage key for collapsible section open/closed state @@ -247,7 +254,8 @@ function NavItems({ const pathname = location.pathname; const [searchParams] = useSearchParams(); const sidebarData = useSidebarData(); - const { setPendingPluginInstallAction } = sidebarData; + const { setPendingPluginInstallAction, setPendingSkillInstallAction } = + sidebarData; const { state: sidebarState, isMobile } = useSidebar(); const { t } = useTranslation(); // Track which entity categories have their full list expanded @@ -323,6 +331,22 @@ function NavItems({ const sectionItems = sidebarConfigList.filter((c) => c.section === section); + // Persist open state for sections that become active through navigation, + // so they remain expanded when the user switches to a different section. + const sectionOpenRef = useRef(sectionOpenState); + sectionOpenRef.current = sectionOpenState; + useEffect(() => { + sectionItems.forEach((config) => { + if (!isEntityCategory(config.id)) return; + const routePrefix = ENTITY_ROUTE_MAP[config.id]; + const active = + pathname === routePrefix || pathname.startsWith(routePrefix + '/'); + if (active && sectionOpenRef.current[config.id] === undefined) { + onSectionToggle(config.id, true); + } + }); + }, [pathname, sectionItems, onSectionToggle]); + return ( <> {sectionItems.map((config) => { @@ -350,6 +374,7 @@ function NavItems({ const canCreate = CREATABLE_CATEGORIES.includes(config.id); const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id); const isPlugin = config.id === 'plugins'; + const isSkill = config.id === 'skills'; const isBot = config.id === 'bots'; const isMCP = config.id === 'mcp'; const isActive = @@ -663,6 +688,61 @@ function NavItems({ + ) : isSkill ? ( + + + + + + { + e.stopPropagation(); + setPendingSkillInstallAction('create'); + navigate('/home/skills'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + setPendingSkillInstallAction('upload'); + navigate('/home/skills'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + setPendingSkillInstallAction('github'); + navigate('/home/skills'); + setPopoverOpen((prev) => ({ + ...prev, + [config.id]: false, + })); + }} + > + + {t('skills.importFromGithub')} + + + ) : ( + + + { + e.stopPropagation(); + setPendingSkillInstallAction('create'); + navigate('/home/skills'); + }} + > + + {t('skills.createManually')} + + { + e.stopPropagation(); + setPendingSkillInstallAction('upload'); + navigate('/home/skills'); + }} + > + + {t('skills.uploadZip')} + + { + e.stopPropagation(); + setPendingSkillInstallAction('github'); + navigate('/home/skills'); + }} + > + + {t('skills.importFromGithub')} + + + ) : ( + {/* Skills Section */} +
+
+

+ {t('pipelines.extensions.skillsTitle')} +

+
+ + +
+
+
+ {enableAllSkills ? ( +
+

+ {t('pipelines.extensions.allSkillsEnabled')} +

+
+ ) : selectedSkills.length === 0 ? ( +
+

+ {t('pipelines.extensions.noSkillsSelected')} +

+
+ ) : ( +
+ {selectedSkills.map((skill) => ( +
+
+
+ +
+
+
+ {skill.display_name || skill.name} +
+
+ {skill.description} +
+
+
+ +
+ ))} +
+ )} +
+ + +
+ {/* Plugin Selection Dialog */} @@ -620,6 +767,73 @@ export default function PipelineExtension({ + + {/* Skill Selection Dialog */} + + + + {t('pipelines.extensions.selectSkills')} + + {allSkills.length > 0 && ( +
+ 0 + } + onCheckedChange={handleToggleAllSkills} + /> + + {t('pipelines.extensions.selectAll')} + +
+ )} +
+ {allSkills.length === 0 ? ( +
+

+ {t('pipelines.extensions.noSkillsAvailable')} +

+
+ ) : ( + allSkills.map((skill) => { + const isSelected = tempSelectedSkillIds.includes(skill.name); + return ( +
handleToggleSkill(skill.name)} + > + +
+ +
+
+
+ {skill.display_name || skill.name} +
+
+ {skill.description} +
+
+
+ ); + }) + )} +
+ + + + +
+
); } diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index 0e4b4fb8..a3d3dd14 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -9,7 +9,6 @@ import { ChevronDownIcon, UploadIcon, StoreIcon, - Download, Power, Github, ChevronLeft, @@ -24,13 +23,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; import { Popover, PopoverContent, @@ -41,6 +33,7 @@ import { CardHeader, CardTitle, CardDescription, + CardContent, } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import React, { useState, useRef, useCallback, useEffect } from 'react'; @@ -72,6 +65,8 @@ interface GithubRelease { published_at: string; prerelease: boolean; draft: boolean; + source_type?: 'release' | 'tag' | 'branch'; + archive_url?: string; } interface GithubAsset { @@ -108,7 +103,7 @@ function PluginListView() { registerOnTaskComplete, unregisterOnTaskComplete, } = usePluginInstallTasks(); - const [modalOpen, setModalOpen] = useState(false); + const [showGithubInstall, setShowGithubInstall] = useState(false); const [installSource, setInstallSource] = useState('local'); const [installInfo] = useState>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [pluginInstallStatus, setPluginInstallStatus] = @@ -256,6 +251,9 @@ function PluginListView() { githubOwner, githubRepo, release.id, + release.tag_name, + release.source_type, + release.archive_url, ); setGithubAssets(result.assets); @@ -319,7 +317,7 @@ function PluginListView() { }); setSelectedTaskId(taskKey); resetGithubState(); - setModalOpen(false); + setShowGithubInstall(false); }) .catch((err) => { setInstallError(err.msg); @@ -340,11 +338,11 @@ function PluginListView() { fileSize: fileSize, }); setSelectedTaskId(taskKey); - setModalOpen(false); }) .catch((err) => { setInstallError(err.msg); setPluginInstallStatus(PluginInstallStatus.ERROR); + toast.error(t('plugins.installFailed') + (err.msg || '')); }); } } @@ -369,7 +367,6 @@ function PluginListView() { if (!(await checkExtensionsLimit())) return; - setModalOpen(true); setPluginInstallStatus(PluginInstallStatus.INSTALLING); setInstallError(null); installPlugin('local', { file }); @@ -449,7 +446,7 @@ function PluginListView() { setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setInstallError(null); resetGithubState(); - setModalOpen(true); + setShowGithubInstall(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingPluginInstallAction, statusLoading, isPluginSystemReady]); @@ -689,7 +686,7 @@ function PluginListView() { setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setInstallError(null); resetGithubState(); - setModalOpen(true); + setShowGithubInstall(true); }} > @@ -699,258 +696,252 @@ function PluginListView() { + {/* Inline GitHub install flow */} + {showGithubInstall && ( +
+ + + + + {t('plugins.installPlugin')} + + + + + {/* Step 1: Enter repo URL */} + {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( +
+

{t('plugins.enterRepoUrl')}

+
+ setGithubURL(e.target.value)} + /> + +
+
+ )} + + {/* Step 2: Select release */} + {pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( +
+
+

+ {t('plugins.selectRelease')} +

+ +
+
+ {githubReleases.map((release) => ( + handleReleaseSelect(release)} + > + +
+ + {release.name || release.tag_name} + + + {t('plugins.releaseTag', { + tag: release.tag_name, + })}{' '} + •{' '} + {t('plugins.publishedAt', { + date: new Date( + release.published_at, + ).toLocaleDateString(), + })} + +
+ {release.prerelease && ( + + {t('plugins.prerelease')} + + )} +
+
+ ))} +
+ {fetchingAssets && ( +

+ {t('plugins.loading')} +

+ )} +
+ )} + + {/* Step 3: Select asset */} + {pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( +
+
+

+ {t('plugins.selectAsset')} +

+ +
+ {selectedRelease && ( +
+
+ {selectedRelease.name || selectedRelease.tag_name} +
+
+ {selectedRelease.tag_name} +
+
+ )} +
+ {githubAssets.map((asset) => ( + handleAssetSelect(asset)} + > + + + {asset.name} + + + {t('plugins.assetSize', { + size: formatFileSize(asset.size), + })} + + + + ))} +
+
+ )} + + {/* Step 4: Confirm install */} + {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( +
+
+

+ {t('plugins.confirmInstall')} +

+ +
+ {selectedRelease && selectedAsset && ( +
+
+ + Repository:{' '} + + + {githubOwner}/{githubRepo} + +
+
+ Release: + + {selectedRelease.tag_name} + +
+
+ File: + {selectedAsset.name} +
+
+ )} +
+ +
+
+ )} + + {/* Installing state */} + {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( +
+

{t('plugins.installing')}

+
+ )} + + {/* Error state */} + {pluginInstallStatus === PluginInstallStatus.ERROR && ( +
+

{t('plugins.installFailed')}

+

{installError}

+
+ +
+
+ )} +
+
+
+ )} + {/* Installed plugins grid */}
- {/* Install plugin dialog (GitHub flow) */} - { - setModalOpen(open); - if (!open) { - resetGithubState(); - setInstallError(null); - } - }} - > - - - - {installSource === 'github' ? ( - - ) : ( - - )} - {t('plugins.installPlugin')} - - - - {/* GitHub Install Flow */} - {installSource === 'github' && - pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && ( -
-

{t('plugins.enterRepoUrl')}

- setGithubURL(e.target.value)} - className="mb-4" - /> - {fetchingReleases && ( -

- {t('plugins.fetchingReleases')} -

- )} -
- )} - - {installSource === 'github' && - pluginInstallStatus === PluginInstallStatus.SELECT_RELEASE && ( -
-
-

{t('plugins.selectRelease')}

- -
-
- {githubReleases.map((release) => ( - handleReleaseSelect(release)} - > - -
- - {release.name || release.tag_name} - - - {t('plugins.releaseTag', { tag: release.tag_name })}{' '} - •{' '} - {t('plugins.publishedAt', { - date: new Date( - release.published_at, - ).toLocaleDateString(), - })} - -
- {release.prerelease && ( - - {t('plugins.prerelease')} - - )} -
-
- ))} -
- {fetchingAssets && ( -

- {t('plugins.loading')} -

- )} -
- )} - - {installSource === 'github' && - pluginInstallStatus === PluginInstallStatus.SELECT_ASSET && ( -
-
-

{t('plugins.selectAsset')}

- -
- {selectedRelease && ( -
-
- {selectedRelease.name || selectedRelease.tag_name} -
-
- {selectedRelease.tag_name} -
-
- )} -
- {githubAssets.map((asset) => ( - handleAssetSelect(asset)} - > - - {asset.name} - - {t('plugins.assetSize', { - size: formatFileSize(asset.size), - })} - - - - ))} -
-
- )} - - {/* GitHub Install Confirm */} - {installSource === 'github' && - pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( -
-
-

{t('plugins.confirmInstall')}

- -
- {selectedRelease && selectedAsset && ( -
-
- Repository: - - {githubOwner}/{githubRepo} - -
-
- Release: - - {selectedRelease.tag_name} - -
-
- File: - {selectedAsset.name} -
-
- )} -
- )} - - {/* Installing State */} - {pluginInstallStatus === PluginInstallStatus.INSTALLING && ( -
-

{t('plugins.installing')}

-
- )} - - {/* Error State */} - {pluginInstallStatus === PluginInstallStatus.ERROR && ( -
-

{t('plugins.installFailed')}

-

{installError}

-
- )} - - - {pluginInstallStatus === PluginInstallStatus.WAIT_INPUT && - installSource === 'github' && ( - <> - - - - )} - {pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && ( - <> - - - - )} - {pluginInstallStatus === PluginInstallStatus.ERROR && ( - - )} - -
-
- {isDragOver && (
diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx new file mode 100644 index 00000000..cc3ad9d6 --- /dev/null +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import SkillForm from '@/app/home/skills/components/skill-form/SkillForm'; + +export default function SkillDetailContent({ id }: { id: string }) { + const isCreateMode = id === 'new'; + const navigate = useNavigate(); + const { t } = useTranslation(); + const { refreshSkills, skills, setDetailEntityName } = useSidebarData(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + useEffect(() => { + if (isCreateMode) { + setDetailEntityName(t('skills.createSkill')); + } else { + const skill = skills.find((item) => item.id === id); + setDetailEntityName(skill?.name ?? id); + } + return () => setDetailEntityName(null); + }, [id, isCreateMode, setDetailEntityName, skills, t]); + + function handleImportedSkills(skillNames: string[]) { + void refreshSkills(); + const primarySkill = skillNames[0]; + if (primarySkill) { + navigate(`/home/skills?id=${encodeURIComponent(primarySkill)}`); + return; + } + navigate('/home/skills'); + } + + function handleSkillUpdated() { + void refreshSkills(); + } + + async function confirmDelete() { + try { + await httpClient.deleteSkill(id); + toast.success(t('skills.deleteSuccess')); + setShowDeleteConfirm(false); + void refreshSkills(); + navigate('/home/skills'); + } catch (error) { + toast.error(t('skills.deleteError') + String(error)); + } + } + + if (isCreateMode) { + return ( +
+
+

{t('skills.createSkill')}

+ +
+ +
+
+ + handleImportedSkills([skillName]) + } + onSkillUpdated={() => {}} + /> +
+
+
+ ); + } + + return ( + <> +
+
+

{t('skills.editSkill')}

+ +
+ +
+
+ + handleImportedSkills([skillName]) + } + onSkillUpdated={handleSkillUpdated} + /> + + + + + {t('skills.dangerZone')} + + + {t('skills.dangerZoneDescription')} + + + +
+
+

{t('common.delete')}

+

+ {t('skills.deleteConfirmation')} +

+
+ +
+
+
+
+
+
+ + + + + {t('common.confirmDelete')} + +
{t('skills.deleteConfirmation')}
+ + + + +
+
+ + ); +} diff --git a/web/src/app/home/skills/components/SkillGithubImportPanel.tsx b/web/src/app/home/skills/components/SkillGithubImportPanel.tsx new file mode 100644 index 00000000..c3eb0941 --- /dev/null +++ b/web/src/app/home/skills/components/SkillGithubImportPanel.tsx @@ -0,0 +1,645 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { ChevronLeft, Github, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import type { Skill } from '@/app/infra/entities/api'; + +interface GithubRelease { + id: number; + tag_name: string; + name: string; + published_at: string; + prerelease: boolean; + draft: boolean; + source_type?: 'release' | 'tag' | 'branch'; + archive_url?: string; +} + +interface GithubAsset { + id: number; + name: string; + size: number; + download_url: string; + content_type: string; +} + +interface PreviewSkill extends Skill { + source_path?: string; + entry_file?: string; +} + +interface SkillGithubImportPanelProps { + onImported: (skillNames: string[]) => void; + /** Which section to display. Defaults to 'all' (both GitHub and upload). */ + mode?: 'all' | 'github' | 'upload'; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +function previewPath(skill: PreviewSkill): string { + return skill.source_path || ''; +} + +export default function SkillGithubImportPanel({ + onImported, + mode = 'all', +}: SkillGithubImportPanelProps) { + const { t } = useTranslation(); + + const [githubURL, setGithubURL] = useState(''); + const [githubOwner, setGithubOwner] = useState(''); + const [githubRepo, setGithubRepo] = useState(''); + const [githubSourceSubdir, setGithubSourceSubdir] = useState(''); + const [githubReleases, setGithubReleases] = useState([]); + const [selectedRelease, setSelectedRelease] = useState( + null, + ); + const [githubAssets, setGithubAssets] = useState([]); + const [selectedAsset, setSelectedAsset] = useState(null); + const [previewSkills, setPreviewSkills] = useState([]); + const [selectedPreviewPaths, setSelectedPreviewPaths] = useState( + [], + ); + const [activePreviewPath, setActivePreviewPath] = useState(''); + const [fetchingReleases, setFetchingReleases] = useState(false); + const [fetchingAssets, setFetchingAssets] = useState(false); + const [previewingGithub, setPreviewingGithub] = useState(false); + const [installingGithub, setInstallingGithub] = useState(false); + + const [uploadFile, setUploadFile] = useState(null); + const [uploadPreviewSkills, setUploadPreviewSkills] = useState< + PreviewSkill[] + >([]); + const [selectedUploadPreviewPaths, setSelectedUploadPreviewPaths] = useState< + string[] + >([]); + const [activeUploadPreviewPath, setActiveUploadPreviewPath] = useState(''); + const [previewingUpload, setPreviewingUpload] = useState(false); + const [installingUpload, setInstallingUpload] = useState(false); + + const [errorMessage, setErrorMessage] = useState(null); + + const activePreviewSkill = + previewSkills.find((skill) => previewPath(skill) === activePreviewPath) || + null; + const activeUploadPreviewSkill = + uploadPreviewSkills.find( + (skill) => previewPath(skill) === activeUploadPreviewPath, + ) || null; + + function initializeSelection( + skills: PreviewSkill[], + setSelectedPaths: (paths: string[]) => void, + setActivePath: (path: string) => void, + ) { + const paths = skills.map(previewPath); + setSelectedPaths(paths); + setActivePath(paths[0] || ''); + } + + function toggleSelection( + targetPath: string, + selectedPaths: string[], + setSelectedPaths: (paths: string[]) => void, + setActivePath: (path: string) => void, + ) { + if (selectedPaths.includes(targetPath)) { + const nextPaths = selectedPaths.filter((path) => path !== targetPath); + setSelectedPaths(nextPaths); + if (!nextPaths.includes(targetPath)) { + setActivePath(nextPaths[0] || targetPath); + } + return; + } + + setSelectedPaths([...selectedPaths, targetPath]); + setActivePath(targetPath); + } + + function buildSourceArchiveAsset(release: GithubRelease): GithubAsset | null { + if (!release.archive_url) return null; + + return { + id: 0, + name: t('skills.sourceArchive'), + size: 0, + download_url: release.archive_url, + content_type: 'application/zip', + }; + } + + async function fetchReleases() { + if (!githubURL.trim()) return; + setFetchingReleases(true); + setErrorMessage(null); + setPreviewSkills([]); + setSelectedPreviewPaths([]); + setActivePreviewPath(''); + + try { + const result = await httpClient.getGithubReleases(githubURL); + setGithubReleases(result.releases); + setGithubOwner(result.owner); + setGithubRepo(result.repo); + setGithubSourceSubdir(result.source_subdir || ''); + + if (result.releases.length === 0) { + toast.warning(t('skills.noReleasesFound')); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.fetchReleasesError')); + } finally { + setFetchingReleases(false); + } + } + + async function handleReleaseSelect(release: GithubRelease) { + setSelectedRelease(release); + setSelectedAsset(null); + setPreviewSkills([]); + setSelectedPreviewPaths([]); + setActivePreviewPath(''); + setErrorMessage(null); + setFetchingAssets(true); + + try { + if (release.source_type && release.source_type !== 'release') { + const archiveAsset = buildSourceArchiveAsset(release); + setGithubAssets(archiveAsset ? [archiveAsset] : []); + if (!archiveAsset) { + toast.warning(t('skills.noAssetsFound')); + } + return; + } + + const result = await httpClient.getGithubReleaseAssets( + githubOwner, + githubRepo, + release.id, + release.tag_name, + release.source_type, + release.archive_url, + ); + let assets = result.assets; + if (assets.length === 0) { + const archiveAsset = buildSourceArchiveAsset(release); + if (archiveAsset) { + assets = [archiveAsset]; + } + } + setGithubAssets(assets); + if (assets.length === 0) { + toast.warning(t('skills.noAssetsFound')); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.fetchAssetsError')); + } finally { + setFetchingAssets(false); + } + } + + async function handleGithubPreview(asset: GithubAsset) { + if (!selectedRelease) return; + + setSelectedAsset(asset); + setPreviewSkills([]); + setSelectedPreviewPaths([]); + setActivePreviewPath(''); + setErrorMessage(null); + setPreviewingGithub(true); + + try { + const resp = await httpClient.previewSkillInstallFromGithub( + asset.download_url, + githubOwner, + githubRepo, + selectedRelease.tag_name, + githubSourceSubdir, + ); + const skills = resp.skills as PreviewSkill[]; + setPreviewSkills(skills); + initializeSelection( + skills, + setSelectedPreviewPaths, + setActivePreviewPath, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.installError')); + } finally { + setPreviewingGithub(false); + } + } + + async function handleGithubImport() { + if (!selectedAsset || !selectedRelease || selectedPreviewPaths.length === 0) + return; + + setInstallingGithub(true); + setErrorMessage(null); + try { + const resp = await httpClient.installSkillFromGithub( + selectedAsset.download_url, + githubOwner, + githubRepo, + selectedRelease.tag_name, + selectedPreviewPaths, + githubSourceSubdir, + ); + toast.success(t('skills.installSuccess')); + onImported(resp.skills.map((skill) => skill.name)); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.installError')); + } finally { + setInstallingGithub(false); + } + } + + async function handleUploadPreview() { + if (!uploadFile) return; + if (!uploadFile.name.toLowerCase().endsWith('.zip')) { + setErrorMessage(t('skills.uploadZipOnly')); + return; + } + + setPreviewingUpload(true); + setUploadPreviewSkills([]); + setSelectedUploadPreviewPaths([]); + setActiveUploadPreviewPath(''); + setErrorMessage(null); + try { + const resp = await httpClient.previewSkillInstallFromUpload(uploadFile); + const skills = resp.skills as PreviewSkill[]; + setUploadPreviewSkills(skills); + initializeSelection( + skills, + setSelectedUploadPreviewPaths, + setActiveUploadPreviewPath, + ); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.installError')); + } finally { + setPreviewingUpload(false); + } + } + + async function handleUploadImport() { + if (!uploadFile || selectedUploadPreviewPaths.length === 0) return; + + setInstallingUpload(true); + setErrorMessage(null); + try { + const resp = await httpClient.installSkillFromUpload( + uploadFile, + selectedUploadPreviewPaths, + ); + toast.success(t('skills.installSuccess')); + onImported(resp.skills.map((skill) => skill.name)); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + setErrorMessage(message || t('skills.installError')); + } finally { + setInstallingUpload(false); + } + } + + function renderCandidateSelector( + skills: PreviewSkill[], + selectedPaths: string[], + activePath: string, + setSelectedPaths: (paths: string[]) => void, + setActivePath: (path: string) => void, + ) { + if (skills.length <= 1) { + return null; + } + + return ( +
+ {skills.map((skill) => { + const path = previewPath(skill); + const selected = selectedPaths.includes(path); + const active = path === activePath; + return ( +
+
+ + toggleSelection( + path, + selectedPaths, + setSelectedPaths, + setActivePath, + ) + } + /> + +
+
+ ); + })} +
+ ); + } + + function renderPreviewDetail(skill: PreviewSkill | null) { + if (!skill) return null; + + return ( + <> +
+
+ {t('skills.displayName')}:{' '} + {skill.display_name || '-'} +
+
+ {t('skills.skillSlug')}:{' '} + {skill.name} +
+
+ {t('skills.skillDescription')}:{' '} + {skill.description} +
+
+ {t('skills.packageRoot')}:{' '} + {skill.package_root} +
+
+ +
+
+ {t('skills.skillInstructions')} +
+
+            {skill.instructions || ''}
+          
+
+ + ); + } + + return ( +
+ {(mode === 'all' || mode === 'github') && ( + + + + + {t('skills.importFromGithub')} + + + + {githubReleases.length === 0 && ( +
+ setGithubURL(e.target.value)} + /> + +
+ )} + + {githubReleases.length > 0 && !selectedRelease && ( +
+ {githubReleases.map((release) => ( + + ))} +
+ )} + + {selectedRelease && previewSkills.length === 0 && ( +
+
+
+
+ {selectedRelease.name || selectedRelease.tag_name} +
+
+ {t('skills.releaseTag', { + tag: selectedRelease.tag_name, + })} +
+
+ +
+ + {fetchingAssets && ( +
+ {t('skills.loading')} +
+ )} + + {!fetchingAssets && githubAssets.length > 0 && ( +
+ {githubAssets.map((asset) => ( + + ))} +
+ )} +
+ )} + + {previewSkills.length > 0 && selectedRelease && selectedAsset && ( +
+
+
{t('skills.preview')}
+ +
+ + {renderCandidateSelector( + previewSkills, + selectedPreviewPaths, + activePreviewPath, + setSelectedPreviewPaths, + setActivePreviewPath, + )} + {renderPreviewDetail(activePreviewSkill)} + +
+ +
+
+ )} +
+
+ )} + + {(mode === 'all' || mode === 'upload') && ( + + + + + {t('skills.uploadZip')} + + + + { + const file = e.target.files?.[0] ?? null; + setUploadFile(file); + setUploadPreviewSkills([]); + setSelectedUploadPreviewPaths([]); + setActiveUploadPreviewPath(''); + setErrorMessage(null); + }} + /> + {uploadFile && ( +
+ {uploadFile.name} +
+ )} + +
+ +
+ + {uploadPreviewSkills.length > 0 && uploadFile && ( +
+
{t('skills.preview')}
+ + {renderCandidateSelector( + uploadPreviewSkills, + selectedUploadPreviewPaths, + activeUploadPreviewPath, + setSelectedUploadPreviewPaths, + setActiveUploadPreviewPath, + )} + {renderPreviewDetail(activeUploadPreviewSkill)} + +
+ +
+
+ )} + + {errorMessage && ( +
{errorMessage}
+ )} +
+
+ )} +
+ ); +} diff --git a/web/src/app/home/skills/components/skill-form/SkillForm.tsx b/web/src/app/home/skills/components/skill-form/SkillForm.tsx new file mode 100644 index 00000000..6710c750 --- /dev/null +++ b/web/src/app/home/skills/components/skill-form/SkillForm.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { FolderSearch, ChevronDown, ChevronRight } from 'lucide-react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Skill } from '@/app/infra/entities/api'; +import { toast } from 'sonner'; + +interface SkillFormProps { + initSkillName?: string; + onNewSkillCreated: (skillName: string) => void; + onSkillUpdated: (skillName: string) => void; +} + +export default function SkillForm({ + initSkillName, + onNewSkillCreated, + onSkillUpdated, +}: SkillFormProps) { + const { t } = useTranslation(); + const [skill, setSkill] = useState>({ + name: '', + display_name: '', + description: '', + instructions: '', + package_root: '', + auto_activate: true, + }); + const [scanning, setScanning] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + + useEffect(() => { + if (initSkillName) { + loadSkill(initSkillName); + return; + } + setSkill({ + name: '', + display_name: '', + description: '', + instructions: '', + package_root: '', + auto_activate: true, + }); + setShowAdvanced(false); + }, [initSkillName]); + + async function loadSkill(skillName: string) { + try { + const resp = await httpClient.getSkill(skillName); + setSkill(resp.skill); + } catch (error) { + console.error('Failed to load skill:', error); + toast.error(t('skills.getSkillListError') + String(error)); + } + } + + async function scanDirectory() { + const path = skill.package_root?.trim(); + if (!path) { + toast.error(t('skills.packageRootRequired')); + return; + } + setScanning(true); + try { + const result = await httpClient.scanSkillDirectory(path); + setSkill((prev) => ({ + ...prev, + name: prev.name || result.name, + display_name: prev.display_name || result.display_name || '', + description: prev.description || result.description, + package_root: result.package_root, + instructions: result.instructions, + auto_activate: result.auto_activate ?? true, + })); + toast.success(t('skills.scanSuccess')); + } catch (error) { + console.error('Failed to scan directory:', error); + toast.error(t('skills.scanError') + String(error)); + } finally { + setScanning(false); + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!skill.name?.trim()) { + toast.error(t('skills.skillNameRequired')); + return; + } + if (!skill.description?.trim()) { + toast.error(t('skills.skillDescriptionRequired')); + return; + } + + const baseSkillData = { + name: skill.name, + display_name: skill.display_name || '', + description: skill.description || '', + instructions: skill.instructions || '', + auto_activate: skill.auto_activate ?? true, + }; + + try { + if (initSkillName) { + const resp = await httpClient.updateSkill(initSkillName, baseSkillData); + toast.success(t('skills.saveSuccess')); + onSkillUpdated(resp.skill.name); + } else { + const skillData: Omit & { name: string } = { + ...baseSkillData, + package_root: skill.package_root || '', + }; + const resp = await httpClient.createSkill(skillData); + toast.success(t('skills.createSuccess')); + onNewSkillCreated(resp.skill.name); + } + } catch (error) { + toast.error( + (initSkillName ? t('skills.saveError') : t('skills.createError')) + + String(error), + ); + } + }; + + return ( +
+
+ + setSkill({ ...skill, display_name: e.target.value })} + placeholder={t('skills.displayNamePlaceholder')} + /> +
+ +
+ + + setSkill({ + ...skill, + name: e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''), + }) + } + placeholder={t('skills.skillSlugPlaceholder')} + className="font-mono" + disabled={Boolean(initSkillName)} + /> +

+ {t('skills.skillSlugHelp')} +

+
+ +
+ +