mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: manage skills through box runtime
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user