From e814f359cb32e28574c2b71716a56d16fcd7aa88 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 16 May 2026 17:14:58 +0800 Subject: [PATCH] feat: manage skills through box runtime --- docker/docker-compose.yaml | 16 +- .../pkg/api/http/controller/groups/skills.py | 6 +- src/langbot/pkg/api/http/service/skill.py | 105 ++++++- src/langbot/pkg/box/connector.py | 18 +- src/langbot/pkg/box/service.py | 73 ++++- .../pkg/provider/tools/loaders/native.py | 285 +++++++++++++++++- .../provider/tools/loaders/skill_authoring.py | 13 +- src/langbot/pkg/skill/manager.py | 28 +- src/langbot/templates/config.yaml | 5 +- 9 files changed, 514 insertions(+), 35 deletions(-) diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 7ac784c2..c816dec3 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -22,7 +22,10 @@ services: image: rockchin/langbot:latest container_name: langbot_box volumes: - - ./data/box:/workspaces + # Keep the source and target path identical because langbot_box uses the + # host Docker socket to create sandbox containers. Override + # LANGBOT_BOX_ROOT with an absolute path if you do not want the default. + - ${LANGBOT_BOX_ROOT:-${PWD}/data/box}:${LANGBOT_BOX_ROOT:-${PWD}/data/box} # Mount container runtime socket for Box sandbox backend. # Uncomment the one that matches your container runtime: # - /var/run/podman/podman.sock:/var/run/podman/podman.sock # Podman @@ -30,6 +33,10 @@ services: restart: on-failure environment: - TZ=Asia/Shanghai + - LANGBOT_BOX_LOCAL_HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - LANGBOT_BOX_LOCAL_DEFAULT_WORKSPACE=default + - LANGBOT_BOX_LOCAL_SKILLS_ROOT=skills + - LANGBOT_BOX_LOCAL_ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box} command: ["uv", "run", "--no-sync", "-m", "langbot_plugin.box", "--mode", "ws"] networks: - langbot_network @@ -39,10 +46,13 @@ services: container_name: langbot volumes: - ./data:/app/data - - ./data/box:/workspaces restart: on-failure environment: - TZ=Asia/Shanghai + - LANGBOT_BOX_LOCAL_HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box} + - LANGBOT_BOX_LOCAL_DEFAULT_WORKSPACE=default + - LANGBOT_BOX_LOCAL_SKILLS_ROOT=skills + - LANGBOT_BOX_LOCAL_ALLOWED_MOUNT_ROOTS=${LANGBOT_BOX_ROOT:-${PWD}/data/box} ports: - 5300:5300 # For web ui and webhook callback - 2280-2285:2280-2285 # For platform reverse connection @@ -51,4 +61,4 @@ services: networks: langbot_network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/src/langbot/pkg/api/http/controller/groups/skills.py b/src/langbot/pkg/api/http/controller/groups/skills.py index 62403163..a3c941c8 100644 --- a/src/langbot/pkg/api/http/controller/groups/skills.py +++ b/src/langbot/pkg/api/http/controller/groups/skills.py @@ -64,7 +64,9 @@ class SkillsRouterGroup(group.RouterGroup): except ValueError as exc: return self.http_status(400, -1, str(exc)) - @self.route('//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + @self.route( + '//files/', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY + ) async def read_or_write_skill_file(skill_name: str, path: str) -> quart.Response: """Read or write a file in skill package.""" if quart.request.method == 'GET': @@ -168,7 +170,7 @@ class SkillsRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'Missing required parameter: path') try: - result = self.ap.skill_service.scan_directory(path) + result = await self.ap.skill_service.scan_directory_async(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/skill.py b/src/langbot/pkg/api/http/service/skill.py index 0b413fd7..e81e8c39 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -68,16 +68,31 @@ class SkillService: def __init__(self, ap: app.Application) -> None: self.ap = ap + def _box_service(self): + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'available', False): + return box_service + return None + @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]: + box_service = self._box_service() + if box_service is not None: + return [self._serialize_skill(skill) for skill in await box_service.list_skills()] + 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]: + box_service = self._box_service() + if box_service is not None: + skill = await box_service.get_skill(skill_name) + return self._serialize_skill(skill) if skill else None + skill = getattr(self.ap.skill_mgr, 'get_skill_by_name', lambda _name: None)(skill_name) return self._serialize_skill(skill) if skill else None @@ -85,6 +100,12 @@ class SkillService: return await self.get_skill(name) async def create_skill(self, data: dict) -> dict: + box_service = self._box_service() + if box_service is not None: + created = await box_service.create_skill(data) + await self._reload_skills() + return self._serialize_skill(created) + 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') @@ -125,6 +146,12 @@ class SkillService: return created async def update_skill(self, skill_name: str, data: dict) -> dict: + box_service = self._box_service() + if box_service is not None: + updated = await box_service.update_skill(skill_name, data) + await self._reload_skills() + return self._serialize_skill(updated) + skill = await self.get_skill(skill_name) if not skill: raise ValueError(f'Skill "{skill_name}" not found') @@ -153,6 +180,12 @@ class SkillService: return updated async def delete_skill(self, skill_name: str) -> bool: + box_service = self._box_service() + if box_service is not None: + await box_service.delete_skill(skill_name) + await self._reload_skills() + return True + skill = await self.get_skill(skill_name) if not skill: raise ValueError(f'Skill "{skill_name}" not found') @@ -173,6 +206,10 @@ class SkillService: include_hidden: bool = False, max_entries: int = 200, ) -> dict: + box_service = self._box_service() + if box_service is not None: + return await box_service.list_skill_files(skill_name, path, include_hidden, max_entries) + skill = await self.get_skill(skill_name) if not skill: raise ValueError(f'Skill "{skill_name}" not found') @@ -204,6 +241,10 @@ class SkillService: } async def read_skill_file(self, skill_name: str, path: str) -> dict: + box_service = self._box_service() + if box_service is not None: + return await box_service.read_skill_file(skill_name, path) + skill = await self.get_skill(skill_name) if not skill: raise ValueError(f'Skill "{skill_name}" not found') @@ -225,6 +266,12 @@ class SkillService: } async def write_skill_file(self, skill_name: str, path: str, content: str) -> dict: + box_service = self._box_service() + if box_service is not None: + result = await box_service.write_skill_file(skill_name, path, content) + await self._reload_skills() + return result + skill = await self.get_skill(skill_name) if not skill: raise ValueError(f'Skill "{skill_name}" not found') @@ -253,6 +300,20 @@ class SkillService: 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() + box_service = self._box_service() + if box_service is not None: + zip_bytes = await self._download_github_asset(asset_url) + filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip' + installed = await box_service.install_skill_zip( + zip_bytes, + filename, + source_paths=data.get('source_paths') or [], + source_path=str(data.get('source_path', '') or ''), + source_subdir=source_subdir, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_') try: skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir) @@ -277,6 +338,15 @@ class SkillService: 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() + box_service = self._box_service() + if box_service is not None: + zip_bytes = await self._download_github_asset(asset_url) + return await box_service.preview_skill_zip( + zip_bytes, + f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip', + source_subdir=source_subdir, + ) + tmp_dir = tempfile.mkdtemp(prefix='langbot_skill_preview_') try: skill_root = await self._download_github_skill_to_temp(asset_url, tmp_dir) @@ -297,6 +367,17 @@ class SkillService: source_paths: list[str] | None = None, source_path: str = '', ) -> list[dict]: + box_service = self._box_service() + if box_service is not None: + installed = await box_service.install_skill_zip( + file_bytes, + filename, + source_paths=source_paths or [], + source_path=source_path, + ) + await self._reload_skills() + return [self._serialize_skill(skill) for skill in installed] + if not file_bytes: raise ValueError('Uploaded file is empty') @@ -321,6 +402,10 @@ class SkillService: return await self._resolve_installed_skills(scanned) async def preview_install_from_zip_upload(self, *, file_bytes: bytes, filename: str) -> list[dict]: + box_service = self._box_service() + if box_service is not None: + return await box_service.preview_skill_zip(file_bytes, filename) + if not file_bytes: raise ValueError('Uploaded file is empty') @@ -368,6 +453,12 @@ class SkillService: 'instructions': instructions, } + async def scan_directory_async(self, path: str) -> dict: + box_service = self._box_service() + if box_service is not None: + return await box_service.scan_skill_directory(path) + return self.scan_directory(path) + async def _reload_skills(self) -> None: skill_mgr = getattr(self.ap, 'skill_mgr', None) reload_skills = getattr(skill_mgr, 'reload_skills', None) @@ -397,11 +488,9 @@ class SkillService: 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) + content = await self._download_github_asset(asset_url) + with open(zip_path, 'wb') as f: + f.write(content) extract_dir = os.path.join(tmp_dir, 'extracted') with zipfile.ZipFile(zip_path, 'r') as zf: @@ -412,6 +501,12 @@ class SkillService: return os.path.join(extract_dir, entries[0]) return extract_dir + async def _download_github_asset(self, asset_url: str) -> bytes: + async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client: + resp = await client.get(asset_url) + resp.raise_for_status() + return resp.content + def _extract_uploaded_skill_to_temp(self, file_bytes: bytes, tmp_dir: str) -> str: extract_dir = os.path.join(tmp_dir, 'extracted') try: diff --git a/src/langbot/pkg/box/connector.py b/src/langbot/pkg/box/connector.py index 18fd7877..8ea8d387 100644 --- a/src/langbot/pkg/box/connector.py +++ b/src/langbot/pkg/box/connector.py @@ -38,7 +38,23 @@ def _get_box_config(ap) -> dict: """Return the 'box' section from instance config, with safe fallbacks.""" instance_config = getattr(ap, 'instance_config', None) config_data = getattr(instance_config, 'data', {}) if instance_config is not None else {} - return config_data.get('box', {}) + box_config = dict(config_data.get('box', {}) or {}) + local_config = dict(box_config.get('local') or {}) + env_overrides = { + 'host_root': os.getenv('LANGBOT_BOX_LOCAL_HOST_ROOT', ''), + 'default_workspace': os.getenv('LANGBOT_BOX_LOCAL_DEFAULT_WORKSPACE', ''), + 'skills_root': os.getenv('LANGBOT_BOX_LOCAL_SKILLS_ROOT', ''), + } + for key, value in env_overrides.items(): + if value: + local_config[key] = value + + allowed_mount_roots = os.getenv('LANGBOT_BOX_LOCAL_ALLOWED_MOUNT_ROOTS', '') + if allowed_mount_roots: + local_config['allowed_mount_roots'] = [item.strip() for item in allowed_mount_roots.split(',') if item.strip()] + if local_config: + box_config['local'] = local_config + return box_config def _get_runtime_endpoint(box_cfg: dict) -> str: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 5d6688dd..91a51f4a 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -300,6 +300,52 @@ class BoxService: ) return getter(session_id, ws_relay_base_url, process_id) + async def list_skills(self) -> list[dict]: + return await self.client.list_skills() + + async def get_skill(self, name: str) -> dict | None: + return await self.client.get_skill(name) + + async def create_skill(self, skill: dict) -> dict: + return await self.client.create_skill(skill) + + async def update_skill(self, name: str, skill: dict) -> dict: + return await self.client.update_skill(name, skill) + + async def delete_skill(self, name: str) -> None: + await self.client.delete_skill(name) + + async def scan_skill_directory(self, path: str) -> dict: + return await self.client.scan_skill_directory(path) + + async def list_skill_files( + self, + name: str, + path: str = '.', + include_hidden: bool = False, + max_entries: int = 200, + ) -> dict: + return await self.client.list_skill_files(name, path, include_hidden, max_entries) + + async def read_skill_file(self, name: str, path: str) -> dict: + return await self.client.read_skill_file(name, path) + + async def write_skill_file(self, name: str, path: str, content: str) -> dict: + return await self.client.write_skill_file(name, path, content) + + async def preview_skill_zip(self, file_bytes: bytes, filename: str, source_subdir: str = '') -> list[dict]: + return await self.client.preview_skill_zip(file_bytes, filename, source_subdir) + + async def install_skill_zip( + self, + file_bytes: bytes, + filename: str, + source_paths: list[str] | None = None, + source_path: str = '', + source_subdir: str = '', + ) -> list[dict]: + return await self.client.install_skill_zip(file_bytes, filename, source_paths, source_path, source_subdir) + def _serialize_result(self, result: BoxExecutionResult) -> dict: stdout, stdout_truncated = self._truncate(result.stdout) stderr, stderr_truncated = self._truncate(result.stderr) @@ -389,7 +435,22 @@ class BoxService: } def _local_config(self) -> dict: - return _get_box_config(self.ap).get('local') or {} + local_config = dict(_get_box_config(self.ap).get('local') or {}) + env_overrides = { + 'host_root': os.getenv('LANGBOT_BOX_LOCAL_HOST_ROOT', ''), + 'default_workspace': os.getenv('LANGBOT_BOX_LOCAL_DEFAULT_WORKSPACE', ''), + 'skills_root': os.getenv('LANGBOT_BOX_LOCAL_SKILLS_ROOT', ''), + } + for key, value in env_overrides.items(): + if value: + local_config[key] = value + + allowed_mount_roots = os.getenv('LANGBOT_BOX_LOCAL_ALLOWED_MOUNT_ROOTS', '') + if allowed_mount_roots: + local_config['allowed_mount_roots'] = [ + item.strip() for item in allowed_mount_roots.split(',') if item.strip() + ] + return local_config def _load_allowed_mount_roots(self) -> list[str]: configured_roots = self._local_config().get('allowed_mount_roots', []) @@ -418,8 +479,18 @@ class BoxService: if self.host_root is None: return None default_workspace = os.path.join(self.host_root, 'default') + elif not os.path.isabs(default_workspace) and self.host_root is not None: + default_workspace = os.path.join(self.host_root, default_workspace) return os.path.realpath(os.path.abspath(default_workspace)) + def get_skills_root(self) -> str | None: + skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip() + if not skills_root: + skills_root = 'skills' + if not os.path.isabs(skills_root) and self.host_root is not None: + skills_root = os.path.join(self.host_root, skills_root) + return os.path.realpath(os.path.abspath(skills_root)) + def _load_custom_image(self) -> str | None: raw = str(self._local_config().get('image', '') or '').strip() return raw or None diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 0b9cee53..d6ef11d1 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -177,15 +177,234 @@ class NativeToolLoader(loader.ToolLoader): return host_path, selected_skill + def _resolve_skill_relative_path( + self, + query: pipeline_query.Query, + sandbox_path: str, + *, + include_visible: bool, + include_activated: bool, + ) -> tuple[dict, str] | None: + selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path( + self.ap, + query, + sandbox_path, + include_visible=include_visible, + include_activated=include_activated, + ) + if selected_skill is None: + return None + + mount_path = '/workspace' + if not rewritten_path.startswith(mount_path): + raise ValueError(f'Path must be under {mount_path}.') + relative = rewritten_path[len(mount_path) :].lstrip('/') or '.' + return selected_skill, relative + + def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool: + if selected_skill is not None: + return False + box_service = getattr(self.ap, 'box_service', None) + if box_service is None or not hasattr(box_service, 'execute_tool'): + return False + default_workspace = getattr(box_service, 'default_workspace', None) + return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace))) + + async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict: + result = await self.ap.box_service.execute_tool( + { + 'command': f"python - <<'PY'\n{script}\nPY", + 'timeout_sec': 30, + }, + query, + ) + if not result.get('ok'): + return {'ok': False, 'error': result.get('stderr') or result.get('stdout') or 'Box execution failed'} + stdout = str(result.get('stdout') or '').strip() + try: + return json.loads(stdout.splitlines()[-1]) + except Exception: + return {'ok': False, 'error': stdout or 'Box file operation returned no result'} + + async def _read_workspace_via_box(self, path: str, query: pipeline_query.Query) -> dict: + script = f""" +import json, os +path = {json.dumps(path)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.exists(path): + print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}})) +elif os.path.isdir(path): + print(json.dumps({{'ok': True, 'content': '\\n'.join(sorted(os.listdir(path))), 'is_directory': True}})) +else: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + print(json.dumps({{'ok': True, 'content': f.read()}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _write_workspace_via_box(self, path: str, content: str, query: pipeline_query.Query) -> dict: + script = f""" +import json, os +path = {json.dumps(path)} +content = {json.dumps(content)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +else: + os.makedirs(os.path.dirname(path) or '/workspace', exist_ok=True) + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + print(json.dumps({{'ok': True, 'path': path}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _edit_workspace_via_box( + self, + path: str, + old_string: str, + new_string: str, + query: pipeline_query.Query, + ) -> dict: + script = f""" +import json, os +path = {json.dumps(path)} +old_string = {json.dumps(old_string)} +new_string = {json.dumps(new_string)} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.isfile(path): + print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}})) +else: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read() + count = content.count(old_string) + if count == 0: + print(json.dumps({{'ok': False, 'error': 'old_string not found in file.'}})) + elif count > 1: + print(json.dumps({{'ok': False, 'error': f'old_string matches {{count}} locations; provide a more unique string.'}})) + else: + with open(path, 'w', encoding='utf-8') as f: + f.write(content.replace(old_string, new_string, 1)) + print(json.dumps({{'ok': True, 'path': path}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _glob_workspace_via_box(self, path: str, pattern: str, query: pipeline_query.Query) -> dict: + script = f""" +import json, os +from pathlib import Path +path = {json.dumps(path)} +pattern = {json.dumps(pattern)} +skip_dirs = {json.dumps(sorted(_SKIP_DIRS))} +if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) +elif not os.path.isdir(path): + print(json.dumps({{'ok': False, 'error': f'Path is not a directory: {{path}}'}})) +else: + base = Path(path) + hits = [ + item for item in base.rglob(pattern) + if not any(part in skip_dirs for part in item.parts) + ] + hits.sort(key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True) + shown = hits[:100] + matches = [] + for item in shown: + rel = os.path.relpath(str(item), path) + matches.append(os.path.join(path, rel).replace(os.sep, '/')) + print(json.dumps({{'ok': True, 'matches': matches, 'total': len(hits), 'truncated': len(hits) > 100}})) +""".strip() + return await self._run_workspace_file_script(script, query) + + async def _grep_workspace_via_box( + self, + path: str, + pattern: str, + include: str | None, + query: pipeline_query.Query, + ) -> dict: + script = f""" +import json, os, re +from pathlib import Path +path = {json.dumps(path)} +pattern = {json.dumps(pattern)} +include = {json.dumps(include)} +skip_dirs = {json.dumps(sorted(_SKIP_DIRS))} +try: + regex = re.compile(pattern) +except re.error as exc: + print(json.dumps({{'ok': False, 'error': f'Invalid regex: {{exc}}'}})) +else: + if not path.startswith('/workspace'): + print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}})) + elif not os.path.exists(path): + print(json.dumps({{'ok': False, 'error': f'Path not found: {{path}}'}})) + else: + base = Path(path) + if base.is_file(): + files = [base] + else: + files = [] + for item in base.rglob(include or '*'): + if any(part in skip_dirs for part in item.parts): + continue + if item.is_file(): + files.append(item) + if len(files) >= 5000: + break + + matches = [] + for fp in files: + try: + text = fp.read_text(errors='ignore') + except OSError: + continue + for lineno, line in enumerate(text.splitlines(), 1): + if regex.search(line): + if base.is_file(): + file_path = path + else: + rel = os.path.relpath(str(fp), path) + file_path = os.path.join(path, rel).replace(os.sep, '/') + matches.append({{'file': file_path, 'line': lineno, 'content': line.rstrip()}}) + if len(matches) >= 200: + break + if len(matches) >= 200: + break + + print(json.dumps({{'ok': True, 'matches': matches, 'total': len(matches), 'truncated': len(matches) >= 200}})) +""".strip() + return await self._run_workspace_file_script(script, query) + 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, _selected_skill = self._resolve_host_path( + skill_request = self._resolve_skill_relative_path( query, path, include_visible=True, include_activated=True, ) + if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'): + selected_skill, relative = skill_request + try: + result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative) + return {'ok': True, 'content': result.get('content', '')} + except Exception: + try: + result = await self.ap.box_service.list_skill_files(selected_skill['name'], relative) + entries = [entry['name'] for entry in result.get('entries', [])] + return {'ok': True, 'content': '\n'.join(sorted(entries)), 'is_directory': True} + except Exception as exc: + return {'ok': False, 'error': str(exc)} + + host_path, selected_skill = self._resolve_host_path( + query, + path, + include_visible=True, + include_activated=True, + ) + if self._should_use_box_workspace_files(selected_skill): + return await self._read_workspace_via_box(path, query) if not os.path.exists(host_path): return {'ok': False, 'error': f'File not found: {path}'} if os.path.isdir(host_path): @@ -199,12 +418,26 @@ 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)}') + skill_request = self._resolve_skill_relative_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'): + selected_skill, relative = skill_request + await self.ap.box_service.write_skill_file(selected_skill['name'], relative, content) + await self.ap.skill_mgr.reload_skills() + return {'ok': True, 'path': path} + host_path, selected_skill = self._resolve_host_path( query, path, include_visible=False, include_activated=True, ) + if self._should_use_box_workspace_files(selected_skill): + return await self._write_workspace_via_box(path, content, query) os.makedirs(os.path.dirname(host_path), exist_ok=True) with open(host_path, 'w', encoding='utf-8') as f: f.write(content) @@ -219,12 +452,41 @@ 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)}' ) + skill_request = self._resolve_skill_relative_path( + query, + path, + include_visible=False, + include_activated=True, + ) + if ( + skill_request is not None + and hasattr(self.ap.box_service, 'read_skill_file') + and hasattr(self.ap.box_service, 'write_skill_file') + ): + selected_skill, relative = skill_request + try: + result = await self.ap.box_service.read_skill_file(selected_skill['name'], relative) + except Exception: + return {'ok': False, 'error': f'File not found: {path}'} + content = result.get('content', '') + count = content.count(old_string) + if count == 0: + return {'ok': False, 'error': 'old_string not found in file.'} + 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) + await self.ap.box_service.write_skill_file(selected_skill['name'], relative, new_content) + await self.ap.skill_mgr.reload_skills() + return {'ok': True, 'path': path} + host_path, selected_skill = self._resolve_host_path( query, path, include_visible=False, include_activated=True, ) + if self._should_use_box_workspace_files(selected_skill): + return await self._edit_workspace_via_box(path, old_string, new_string, query) if not os.path.isfile(host_path): return {'ok': False, 'error': f'File not found: {path}'} with open(host_path, 'r', encoding='utf-8', errors='replace') as f: @@ -453,12 +715,14 @@ class NativeToolLoader(loader.ToolLoader): path = str(parameters.get('path', '/workspace') or '/workspace') self.ap.logger.info(f'glob tool invoked: query_id={query.query_id} pattern={pattern} path={path}') - host_path, _selected_skill = self._resolve_host_path( + host_path, selected_skill = self._resolve_host_path( query, path, include_visible=True, include_activated=True, ) + if self._should_use_box_workspace_files(selected_skill): + return await self._glob_workspace_via_box(path, pattern, query) if not os.path.isdir(host_path): return {'ok': False, 'error': f'Path is not a directory: {path}'} @@ -506,12 +770,14 @@ class NativeToolLoader(loader.ToolLoader): except re.error as e: return {'ok': False, 'error': f'Invalid regex: {e}'} - host_path, _selected_skill = self._resolve_host_path( + host_path, selected_skill = self._resolve_host_path( query, path, include_visible=True, include_activated=True, ) + if self._should_use_box_workspace_files(selected_skill): + return await self._grep_workspace_via_box(path, pattern, include, query) if not os.path.exists(host_path): return {'ok': False, 'error': f'Path not found: {path}'} @@ -533,11 +799,13 @@ class NativeToolLoader(loader.ToolLoader): if regex.search(line): rel = os.path.relpath(str(fp), host_path) sandbox_path = os.path.join(path, rel) - matches.append({ - 'file': sandbox_path, - 'line': lineno, - 'content': line.rstrip(), - }) + matches.append( + { + 'file': sandbox_path, + 'line': lineno, + 'content': line.rstrip(), + } + ) if len(matches) >= 200: break if len(matches) >= 200: @@ -553,7 +821,6 @@ class NativeToolLoader(loader.ToolLoader): @staticmethod def _grep_walk(root, include: str | None) -> list: """Walk dir tree for grep, skipping junk dirs.""" - from pathlib import Path results = [] for item in root.rglob(include or '*'): if any(skip in item.parts for skip in _SKIP_DIRS): diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index d8d41349..9d0fe6e9 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -139,21 +139,13 @@ class SkillToolLoader(loader.ToolLoader): # Resolve sandbox path to host path host_path = self._resolve_workspace_directory(sandbox_path) - # Verify SKILL.md exists - skill_md_path = os.path.join(host_path, 'SKILL.md') - if not os.path.isfile(skill_md_path): - # Try skill.md as alternative - skill_md_path = os.path.join(host_path, 'skill.md') - if not os.path.isfile(skill_md_path): - raise ValueError(f'SKILL.md not found in directory: {sandbox_path}') - # Get or create skill service skill_service = getattr(self.ap, 'skill_service', None) if skill_service is None: raise ValueError('Skill service not available') # Scan and register the skill - scanned = skill_service.scan_directory(host_path) + scanned = await skill_service.scan_directory_async(host_path) # Override name if provided skill_name = str(parameters.get('name') or scanned['name']).strip() @@ -197,6 +189,9 @@ class SkillToolLoader(loader.ToolLoader): if not (host_path == host_root or host_path.startswith(host_root + os.sep)): raise ValueError('path escapes the workspace boundary') + if getattr(box_service, 'available', False): + return host_path + if not os.path.isdir(host_path): raise ValueError(f'Directory does not exist: {sandbox_path}') diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py index c5578743..d5d94351 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -13,9 +13,10 @@ if typing.TYPE_CHECKING: class SkillManager: - """Skill manager backed by filesystem packages. + """Skill manager backed by Box-managed or local filesystem packages. - Skills are loaded from data/skills/ and managed by users. + In sandbox deployments, skills are loaded from the Box runtime. Local + data/skills remains as the fallback for non-Box development. Skills are activated through the `activate` tool (Tool Call mechanism), aligned with Claude Code's design. This protects KV Cache and follows @@ -35,7 +36,10 @@ class SkillManager: async def reload_skills(self): """Reload all skills. - All skills are loaded from data/skills/. + In sandbox deployments, skills are owned by the Box runtime so the + sandbox can mount them without requiring LangBot to share the same + filesystem. If Box is unavailable, fall back to the legacy local + data/skills directory. NOTE: This performs a full scan. For registering a single new skill, consider adding it directly to self.skills instead of reloading all. @@ -43,6 +47,18 @@ class SkillManager: """ self.skills = {} + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'available', False): + try: + for skill_data in await box_service.list_skills(): + skill_name = skill_data.get('name') + if skill_name: + self.skills[skill_name] = skill_data + self.ap.logger.info(f'Loaded {len(self.skills)} skills from Box runtime') + return + except Exception as exc: + self.ap.logger.warning(f'Failed to load skills from Box runtime, falling back to local data: {exc}') + # Ensure data/skills/ exists managed_root = self.get_managed_skills_root() os.makedirs(managed_root, exist_ok=True) @@ -67,6 +83,12 @@ class SkillManager: if not skill_name: return False + box_service = getattr(self.ap, 'box_service', None) + if box_service is not None and getattr(box_service, 'available', False): + # Box refresh is async; callers that need a guaranteed refresh call + # SkillService.write_skill_file/update_skill, which awaits reload. + return skill_name in self.skills + skill_data = self.skills.get(skill_name) if not skill_data: return False diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index aca9cd95..ec70cd7f 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -111,8 +111,9 @@ box: local: profile: 'default' image: '' # Custom local sandbox image. Leave empty to use the profile default. - host_root: './data/box' # Base host directory for local workspace mounts. For Docker deployment, use '/workspaces'. - default_workspace: '' # Defaults to '/default'. + host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path. + default_workspace: '' # Defaults to '/default'. Relative paths are resolved under host_root. + skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root. allowed_mount_roots: # Defaults to [''] when left empty. - './data/box' - '/tmp'