feat: manage skills through box runtime

This commit is contained in:
Junyan Qin
2026-05-16 17:14:58 +08:00
parent c1f5ba1927
commit e814f359cb
9 changed files with 514 additions and 35 deletions

View File

@@ -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

View File

@@ -64,7 +64,9 @@ class SkillsRouterGroup(group.RouterGroup):
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@self.route('/<skill_name>/files/<path:path>', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
@self.route(
'/<skill_name>/files/<path:path>', 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))

View File

@@ -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()
content = await self._download_github_asset(asset_url)
with open(zip_path, 'wb') as f:
f.write(resp.content)
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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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({
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):

View File

@@ -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}')

View File

@@ -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

View File

@@ -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 '<host_root>/default'.
host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path.
default_workspace: '' # Defaults to '<host_root>/default'. Relative paths are resolved under host_root.
skills_root: 'skills' # Box-owned skill package directory. Relative paths are resolved under host_root.
allowed_mount_roots: # Defaults to ['<host_root>'] when left empty.
- './data/box'
- '/tmp'