refactor(skills): Unified skill storage capability (#2613)

This commit is contained in:
Xun
2026-05-01 13:23:26 +08:00
committed by GitHub
parent eba3b9e18d
commit 1ad1420e31
29 changed files with 1031 additions and 968 deletions
@@ -8,8 +8,8 @@ from functools import lru_cache
from typing import TYPE_CHECKING
from deerflow.config.agents_config import load_agent_soul
from deerflow.skills import load_skills
from deerflow.skills.types import Skill
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names
if TYPE_CHECKING:
@@ -26,7 +26,7 @@ _enabled_skills_refresh_event = threading.Event()
def _load_enabled_skills_sync() -> list[Skill]:
return list(load_skills(enabled_only=True))
return list(get_or_new_skill_storage().load_skills(enabled_only=True))
def _start_enabled_skills_refresh_thread() -> None:
@@ -127,11 +127,11 @@ def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[
"""
if app_config is None:
return _get_enabled_skills()
return list(load_skills(enabled_only=True, app_config=app_config))
return list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))
def _skill_mutability_label(category: str) -> str:
return "[custom, editable]" if category == "custom" else "[built-in]"
def _skill_mutability_label(category: SkillCategory | str) -> str:
return "[custom, editable]" if category == SkillCategory.CUSTOM else "[built-in]"
def clear_skills_system_prompt_cache() -> None:
+8 -10
View File
@@ -41,7 +41,7 @@ from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig
from deerflow.config.paths import get_paths
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.skills.installer import install_skill_from_archive
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.uploads.manager import (
claim_unique_filename,
delete_file_safe,
@@ -752,8 +752,6 @@ class DeerFlowClient:
Dict with "skills" key containing list of skill info dicts,
matching the Gateway API ``SkillsListResponse`` schema.
"""
from deerflow.skills.loader import load_skills
return {
"skills": [
{
@@ -763,7 +761,7 @@ class DeerFlowClient:
"category": s.category,
"enabled": s.enabled,
}
for s in load_skills(enabled_only=enabled_only)
for s in get_or_new_skill_storage().load_skills(enabled_only=enabled_only)
]
}
@@ -872,9 +870,9 @@ class DeerFlowClient:
Returns:
Skill info dict, or None if not found.
"""
from deerflow.skills.loader import load_skills
from deerflow.skills.storage import get_or_new_skill_storage
skill = next((s for s in load_skills(enabled_only=False) if s.name == name), None)
skill = next((s for s in get_or_new_skill_storage().load_skills(enabled_only=False) if s.name == name), None)
if skill is None:
return None
return {
@@ -899,9 +897,9 @@ class DeerFlowClient:
ValueError: If the skill is not found.
OSError: If the config file cannot be written.
"""
from deerflow.skills.loader import load_skills
from deerflow.skills.storage import get_or_new_skill_storage
skills = load_skills(enabled_only=False)
skills = get_or_new_skill_storage().load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == name), None)
if skill is None:
raise ValueError(f"Skill '{name}' not found")
@@ -924,7 +922,7 @@ class DeerFlowClient:
self._agent_config_key = None
reload_extensions_config()
updated = next((s for s in load_skills(enabled_only=False) if s.name == name), None)
updated = next((s for s in get_or_new_skill_storage().load_skills(enabled_only=False) if s.name == name), None)
if updated is None:
raise RuntimeError(f"Skill '{name}' disappeared after update")
return {
@@ -948,7 +946,7 @@ class DeerFlowClient:
FileNotFoundError: If the file does not exist.
ValueError: If the file is invalid.
"""
return install_skill_from_archive(skill_path)
return get_or_new_skill_storage().install_skill_from_archive(skill_path)
# ------------------------------------------------------------------
# Public API — memory management
@@ -11,6 +11,10 @@ def _default_repo_root() -> Path:
class SkillsConfig(BaseModel):
"""Configuration for skills system"""
use: str = Field(
default="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
description="Class path of the SkillStorage implementation.",
)
path: str | None = Field(
default=None,
description="Path to skills directory. If not specified, defaults to ../skills relative to backend directory",
@@ -35,10 +39,8 @@ class SkillsConfig(BaseModel):
path = _default_repo_root() / path
return path.resolve()
else:
# Default: ../skills relative to backend directory
from deerflow.skills.loader import get_skills_root_path
return get_skills_root_path()
# Default: <repo_root>/skills
return _default_repo_root() / "skills"
def get_skill_container_path(self, skill_name: str, category: str = "public") -> str:
"""
@@ -1,16 +1,17 @@
from .installer import SkillAlreadyExistsError, SkillSecurityScanError, ainstall_skill_from_archive, install_skill_from_archive
from .loader import get_skills_root_path, load_skills
from __future__ import annotations
from .installer import SkillAlreadyExistsError, SkillSecurityScanError
from .storage import LocalSkillStorage, SkillStorage, get_or_new_skill_storage
from .types import Skill
from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmatter
__all__ = [
"load_skills",
"get_skills_root_path",
"Skill",
"ALLOWED_FRONTMATTER_PROPERTIES",
"_validate_skill_frontmatter",
"install_skill_from_archive",
"ainstall_skill_from_archive",
"SkillAlreadyExistsError",
"SkillSecurityScanError",
"SkillStorage",
"LocalSkillStorage",
"get_or_new_skill_storage",
]
@@ -10,13 +10,10 @@ import logging
import posixpath
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from deerflow.skills.loader import get_skills_root_path
from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.validation import _validate_skill_frontmatter
logger = logging.getLogger(__name__)
@@ -195,80 +192,6 @@ async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str
await _scan_skill_file_or_raise(skill_dir, path, skill_name, executable=_is_script_support_file(rel_path))
async def ainstall_skill_from_archive(
zip_path: str | Path,
*,
skills_root: Path | None = None,
) -> dict:
"""Install a skill from a .skill archive (ZIP).
Args:
zip_path: Path to the .skill file.
skills_root: Override the skills root directory. If None, uses
the default from config.
Returns:
Dict with success, skill_name, message.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the file is invalid (wrong extension, bad ZIP,
invalid frontmatter, duplicate name).
"""
logger.info("Installing skill from %s", zip_path)
path = Path(zip_path)
if not path.is_file():
if not path.exists():
raise FileNotFoundError(f"Skill file not found: {zip_path}")
raise ValueError(f"Path is not a file: {zip_path}")
if path.suffix != ".skill":
raise ValueError("File must have .skill extension")
if skills_root is None:
skills_root = get_skills_root_path()
custom_dir = skills_root / "custom"
custom_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
try:
zf = zipfile.ZipFile(path, "r")
except FileNotFoundError:
raise FileNotFoundError(f"Skill file not found: {zip_path}") from None
except (zipfile.BadZipFile, IsADirectoryError):
raise ValueError("File is not a valid ZIP archive") from None
with zf:
safe_extract_skill_archive(zf, tmp_path)
skill_dir = resolve_skill_dir_from_archive(tmp_path)
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
if not is_valid:
raise ValueError(f"Invalid skill: {message}")
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
raise ValueError(f"Invalid skill name: {skill_name}")
target = custom_dir / skill_name
if target.exists():
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root:
staging_target = Path(staging_root) / skill_name
shutil.copytree(skill_dir, staging_target)
_move_staged_skill_into_reserved_target(staging_target, target)
logger.info("Skill %r installed to %s", skill_name, target)
return {
"success": True,
"skill_name": skill_name,
"message": f"Skill '{skill_name}' installed successfully",
}
def _run_async_install(coro):
try:
loop = asyncio.get_running_loop()
@@ -279,12 +202,3 @@ def _run_async_install(coro):
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(asyncio.run, coro).result()
return asyncio.run(coro)
def install_skill_from_archive(
zip_path: str | Path,
*,
skills_root: Path | None = None,
) -> dict:
"""Install a skill from a .skill archive (ZIP)."""
return _run_async_install(ainstall_skill_from_archive(zip_path, skills_root=skills_root))
@@ -1,105 +0,0 @@
import logging
import os
from pathlib import Path
from deerflow.config.app_config import AppConfig
from .parser import parse_skill_file
from .types import Skill
logger = logging.getLogger(__name__)
def get_skills_root_path() -> Path:
"""
Get the root path of the skills directory.
Returns:
Path to the skills directory (deer-flow/skills)
"""
# loader.py lives at packages/harness/deerflow/skills/loader.py — 5 parents up reaches backend/
backend_dir = Path(__file__).resolve().parent.parent.parent.parent.parent
# skills directory is sibling to backend directory
skills_dir = backend_dir.parent / "skills"
return skills_dir
def load_skills(skills_path: Path | None = None, use_config: bool = True, enabled_only: bool = False, *, app_config: AppConfig | None = None) -> list[Skill]:
"""
Load all skills from the skills directory.
Scans both public and custom skill directories, parsing SKILL.md files
to extract metadata. The enabled state is determined by the skills_state_config.json file.
Args:
skills_path: Optional custom path to skills directory.
If not provided and use_config is True, uses path from config.
Otherwise defaults to deer-flow/skills
use_config: Whether to load skills path from config (default: True)
enabled_only: If True, only return enabled skills (default: False)
Returns:
List of Skill objects, sorted by name
"""
if skills_path is None:
if use_config:
try:
from deerflow.config import get_app_config
config = app_config or get_app_config()
skills_path = config.skills.get_skills_path()
except Exception:
# Fallback to default if config fails
skills_path = get_skills_root_path()
else:
skills_path = get_skills_root_path()
if not skills_path.exists():
return []
skills_by_name: dict[str, Skill] = {}
# Scan public and custom directories
for category in ["public", "custom"]:
category_path = skills_path / category
if not category_path.exists() or not category_path.is_dir():
continue
for current_root, dir_names, file_names in os.walk(category_path, followlinks=True):
# Keep traversal deterministic and skip hidden directories.
dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
if "SKILL.md" not in file_names:
continue
skill_file = Path(current_root) / "SKILL.md"
relative_path = skill_file.parent.relative_to(category_path)
skill = parse_skill_file(skill_file, category=category, relative_path=relative_path)
if skill:
skills_by_name[skill.name] = skill
skills = list(skills_by_name.values())
# Load skills state configuration and update enabled status
# NOTE: We use ExtensionsConfig.from_file() instead of get_extensions_config()
# to always read the latest configuration from disk. This ensures that changes
# made through the Gateway API (which runs in a separate process) are immediately
# reflected in the LangGraph Server when loading skills.
try:
from deerflow.config.extensions_config import ExtensionsConfig
extensions_config = ExtensionsConfig.from_file()
for skill in skills:
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
except Exception as e:
# If config loading fails, default to all enabled
logger.warning("Failed to load extensions config: %s", e)
# Filter by enabled status if requested
if enabled_only:
skills = [skill for skill in skills if skill.enabled]
# Sort by name for consistent ordering
skills.sort(key=lambda s: s.name)
return skills
@@ -1,161 +0,0 @@
"""Utilities for managing custom skills and their history."""
from __future__ import annotations
import json
import re
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.skills.loader import load_skills
from deerflow.skills.validation import _validate_skill_frontmatter
SKILL_FILE_NAME = "SKILL.md"
HISTORY_FILE_NAME = "HISTORY.jsonl"
HISTORY_DIR_NAME = ".history"
ALLOWED_SUPPORT_SUBDIRS = {"references", "templates", "scripts", "assets"}
_SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def get_skills_root_dir(*, app_config: AppConfig | None = None) -> Path:
config = app_config or get_app_config()
return config.skills.get_skills_path()
def get_public_skills_dir(*, app_config: AppConfig | None = None) -> Path:
return get_skills_root_dir(app_config=app_config) / "public"
def get_custom_skills_dir(*, app_config: AppConfig | None = None) -> Path:
path = get_skills_root_dir(app_config=app_config) / "custom"
path.mkdir(parents=True, exist_ok=True)
return path
def validate_skill_name(name: str) -> str:
normalized = name.strip()
if not _SKILL_NAME_PATTERN.fullmatch(normalized):
raise ValueError("Skill name must be hyphen-case using lowercase letters, digits, and hyphens only.")
if len(normalized) > 64:
raise ValueError("Skill name must be 64 characters or fewer.")
return normalized
def get_custom_skill_dir(name: str, *, app_config: AppConfig | None = None) -> Path:
return get_custom_skills_dir(app_config=app_config) / validate_skill_name(name)
def get_custom_skill_file(name: str, *, app_config: AppConfig | None = None) -> Path:
return get_custom_skill_dir(name, app_config=app_config) / SKILL_FILE_NAME
def get_custom_skill_history_dir(*, app_config: AppConfig | None = None) -> Path:
path = get_custom_skills_dir(app_config=app_config) / HISTORY_DIR_NAME
path.mkdir(parents=True, exist_ok=True)
return path
def get_skill_history_file(name: str, *, app_config: AppConfig | None = None) -> Path:
return get_custom_skill_history_dir(app_config=app_config) / f"{validate_skill_name(name)}.jsonl"
def get_public_skill_dir(name: str, *, app_config: AppConfig | None = None) -> Path:
return get_public_skills_dir(app_config=app_config) / validate_skill_name(name)
def custom_skill_exists(name: str, *, app_config: AppConfig | None = None) -> bool:
return get_custom_skill_file(name, app_config=app_config).exists()
def public_skill_exists(name: str, *, app_config: AppConfig | None = None) -> bool:
return (get_public_skill_dir(name, app_config=app_config) / SKILL_FILE_NAME).exists()
def ensure_custom_skill_is_editable(name: str, *, app_config: AppConfig | None = None) -> None:
if custom_skill_exists(name, app_config=app_config):
return
if public_skill_exists(name, app_config=app_config):
raise ValueError(f"'{name}' is a built-in skill. To customise it, create a new skill with the same name under skills/custom/.")
raise FileNotFoundError(f"Custom skill '{name}' not found.")
def ensure_safe_support_path(name: str, relative_path: str, *, app_config: AppConfig | None = None) -> Path:
skill_dir = get_custom_skill_dir(name, app_config=app_config).resolve()
if not relative_path or relative_path.endswith("/"):
raise ValueError("Supporting file path must include a filename.")
relative = Path(relative_path)
if relative.is_absolute():
raise ValueError("Supporting file path must be relative.")
if any(part in {"..", ""} for part in relative.parts):
raise ValueError("Supporting file path must not contain parent-directory traversal.")
top_level = relative.parts[0] if relative.parts else ""
if top_level not in ALLOWED_SUPPORT_SUBDIRS:
raise ValueError(f"Supporting files must live under one of: {', '.join(sorted(ALLOWED_SUPPORT_SUBDIRS))}.")
target = (skill_dir / relative).resolve()
allowed_root = (skill_dir / top_level).resolve()
try:
target.relative_to(allowed_root)
except ValueError as exc:
raise ValueError("Supporting file path must stay within the selected support directory.") from exc
return target
def validate_skill_markdown_content(name: str, content: str) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
temp_skill_dir = Path(tmp_dir) / validate_skill_name(name)
temp_skill_dir.mkdir(parents=True, exist_ok=True)
(temp_skill_dir / SKILL_FILE_NAME).write_text(content, encoding="utf-8")
is_valid, message, parsed_name = _validate_skill_frontmatter(temp_skill_dir)
if not is_valid:
raise ValueError(message)
if parsed_name != name:
raise ValueError(f"Frontmatter name '{parsed_name}' must match requested skill name '{name}'.")
def atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(path.parent)) as tmp_file:
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
tmp_path.replace(path)
def append_history(name: str, record: dict[str, Any], *, app_config: AppConfig | None = None) -> None:
history_path = get_skill_history_file(name, app_config=app_config)
history_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"ts": datetime.now(UTC).isoformat(),
**record,
}
with history_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False))
f.write("\n")
def read_history(name: str, *, app_config: AppConfig | None = None) -> list[dict[str, Any]]:
history_path = get_skill_history_file(name, app_config=app_config)
if not history_path.exists():
return []
records: list[dict[str, Any]] = []
for line in history_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
records.append(json.loads(line))
return records
def list_custom_skills(*, app_config: AppConfig | None = None) -> list:
return [skill for skill in load_skills(enabled_only=False, app_config=app_config) if skill.category == "custom"]
def read_custom_skill_content(name: str, *, app_config: AppConfig | None = None) -> str:
skill_file = get_custom_skill_file(name, app_config=app_config)
if not skill_file.exists():
raise FileNotFoundError(f"Custom skill '{name}' not found.")
return skill_file.read_text(encoding="utf-8")
@@ -4,24 +4,24 @@ from pathlib import Path
import yaml
from .types import Skill
from .types import SKILL_MD_FILE, Skill, SkillCategory
logger = logging.getLogger(__name__)
def parse_skill_file(skill_file: Path, category: str, relative_path: Path | None = None) -> Skill | None:
def parse_skill_file(skill_file: Path, category: SkillCategory, relative_path: Path | None = None) -> Skill | None:
"""Parse a SKILL.md file and extract metadata.
Args:
skill_file: Path to the SKILL.md file.
category: Category of the skill ('public' or 'custom').
category: Category of the skill.
relative_path: Relative path from the category root to the skill
directory. Defaults to the skill directory name when omitted.
Returns:
Skill object if parsing succeeds, None otherwise.
"""
if not skill_file.exists() or skill_file.name != "SKILL.md":
if not skill_file.exists() or skill_file.name != SKILL_MD_FILE:
return None
try:
@@ -10,6 +10,7 @@ from dataclasses import dataclass
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.skills.types import SKILL_MD_FILE
logger = logging.getLogger(__name__)
@@ -36,7 +37,7 @@ def _extract_json_object(raw: str) -> dict | None:
return None
async def scan_skill_content(content: str, *, executable: bool = False, location: str = "SKILL.md", app_config: AppConfig | None = None) -> ScanResult:
async def scan_skill_content(content: str, *, executable: bool = False, location: str = SKILL_MD_FILE, app_config: AppConfig | None = None) -> ScanResult:
"""Screen skill content before it is written to disk."""
rubric = (
"You are a security reviewer for AI agent skills. "
@@ -0,0 +1,83 @@
"""SkillStorage singleton + reflection-based factory.
Mirrors the pattern used by ``deerflow/sandbox/sandbox_provider.py``.
"""
from __future__ import annotations
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
from deerflow.skills.storage.skill_storage import SkillStorage
_default_skill_storage: SkillStorage | None = None
_default_skill_storage_config: object | None = None # AppConfig identity the singleton was built from
def get_or_new_skill_storage(**kwargs) -> SkillStorage:
"""Return a ``SkillStorage`` instance — either a new one or the process singleton.
**New instance** is created (never cached) when:
- ``skills_path`` is provided — uses it as the ``host_path`` override (class still resolved via config).
- ``app_config`` is provided — constructs a storage from ``app_config.skills``
so that per-request config (e.g. Gateway ``Depends(get_config)``) is respected
without polluting the process-level singleton.
**Singleton** is returned (created on first call, then reused) when neither
``skills_path`` nor ``app_config`` is given — uses ``get_app_config()`` to
resolve the active configuration.
"""
global _default_skill_storage, _default_skill_storage_config
from deerflow.config import get_app_config
from deerflow.config.skills_config import SkillsConfig
def _make_storage(skills_config: SkillsConfig, *, host_path: str | None = None, **kwargs) -> SkillStorage:
from deerflow.reflection import resolve_class
cls = resolve_class(skills_config.use, SkillStorage)
return cls(
host_path=host_path if host_path is not None else str(skills_config.get_skills_path()),
container_path=skills_config.container_path,
**kwargs,
)
skills_path = kwargs.pop("skills_path", None)
app_config = kwargs.pop("app_config", None)
if skills_path is not None:
if app_config is not None:
return _make_storage(app_config.skills, host_path=str(skills_path), **kwargs)
# No app_config: use a default SkillsConfig so we never need to read config.yaml
# when the caller has already supplied an explicit host path.
from deerflow.config.skills_config import SkillsConfig
return _make_storage(SkillsConfig(), host_path=str(skills_path), **kwargs)
if app_config is not None:
return _make_storage(app_config.skills, **kwargs)
# If the singleton was manually injected (e.g. in tests) without a config
# identity (_default_skill_storage_config is None), skip get_app_config()
# entirely to avoid requiring a config.yaml on disk.
if _default_skill_storage is not None and _default_skill_storage_config is None:
return _default_skill_storage
app_config_now = get_app_config()
if _default_skill_storage is None or _default_skill_storage_config is not app_config_now:
_default_skill_storage = _make_storage(app_config_now.skills, **kwargs)
_default_skill_storage_config = app_config_now
return _default_skill_storage
def reset_skill_storage() -> None:
"""Clear the cached singleton (used in tests and hot-reload scenarios)."""
global _default_skill_storage, _default_skill_storage_config
_default_skill_storage = None
_default_skill_storage_config = None
__all__ = [
"LocalSkillStorage",
"SkillStorage",
"get_or_new_skill_storage",
"reset_skill_storage",
]
@@ -0,0 +1,198 @@
"""Local-filesystem implementation of ``SkillStorage``."""
from __future__ import annotations
import errno
import json
import logging
import os
import shutil
import tempfile
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path
from deerflow.config.skills_config import _default_repo_root
from deerflow.skills.storage.skill_storage import SKILL_MD_FILE, SkillStorage
from deerflow.skills.types import SkillCategory
logger = logging.getLogger(__name__)
DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills"
class LocalSkillStorage(SkillStorage):
"""Skill storage backed by the local filesystem.
Layout::
<root>/public/<name>/SKILL.md
<root>/custom/<name>/SKILL.md
<root>/custom/.history/<name>.jsonl
"""
def __init__(
self,
host_path: str | None = None,
container_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
app_config=None,
) -> None:
super().__init__(container_path=container_path)
if host_path is None:
from deerflow.config import get_app_config
config = app_config or get_app_config()
self._host_root: Path = config.skills.get_skills_path()
else:
path = Path(host_path)
if not path.is_absolute():
path = _default_repo_root() / path
self._host_root = path.resolve()
# ------------------------------------------------------------------
# Abstract operation implementations
# ------------------------------------------------------------------
def get_skills_root_path(self) -> Path:
return self._host_root
def custom_skill_exists(self, name: str) -> bool:
return self.get_custom_skill_file(name).exists()
def public_skill_exists(self, name: str) -> bool:
normalized_name = self.validate_skill_name(name)
return (self._host_root / SkillCategory.PUBLIC.value / normalized_name / SKILL_MD_FILE).exists()
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
if not self._host_root.exists():
return
for category in SkillCategory:
category_path = self._host_root / category.value
if not category_path.exists() or not category_path.is_dir():
continue
for current_root, dir_names, file_names in os.walk(category_path, followlinks=True):
dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
if SKILL_MD_FILE not in file_names:
continue
yield category, category_path, Path(current_root) / SKILL_MD_FILE
def read_custom_skill(self, name: str) -> str:
if not self.custom_skill_exists(name):
raise FileNotFoundError(f"Custom skill '{name}' not found.")
return (self.get_custom_skill_dir(name) / SKILL_MD_FILE).read_text(encoding="utf-8")
def write_custom_skill(self, name: str, relative_path: str, content: str) -> None:
target = self.validate_relative_path(relative_path, self.get_custom_skill_dir(name))
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(target.parent),
) as tmp_file:
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
tmp_path.replace(target)
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
import zipfile
from deerflow.skills.installer import (
SkillAlreadyExistsError,
_move_staged_skill_into_reserved_target,
_scan_skill_archive_contents_or_raise,
resolve_skill_dir_from_archive,
safe_extract_skill_archive,
)
from deerflow.skills.validation import _validate_skill_frontmatter
logger.info("Installing skill from %s", archive_path)
path = Path(archive_path)
if not path.is_file():
if not path.exists():
raise FileNotFoundError(f"Skill file not found: {archive_path}")
raise ValueError(f"Path is not a file: {archive_path}")
if path.suffix != ".skill":
raise ValueError("File must have .skill extension")
custom_dir = self._host_root / "custom"
custom_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
try:
zf = zipfile.ZipFile(path, "r")
except FileNotFoundError:
raise FileNotFoundError(f"Skill file not found: {archive_path}") from None
except (zipfile.BadZipFile, IsADirectoryError):
raise ValueError("File is not a valid ZIP archive") from None
with zf:
safe_extract_skill_archive(zf, tmp_path)
skill_dir = resolve_skill_dir_from_archive(tmp_path)
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
if not is_valid:
raise ValueError(f"Invalid skill: {message}")
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
raise ValueError(f"Invalid skill name: {skill_name}")
target = custom_dir / skill_name
if target.exists():
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root:
staging_target = Path(staging_root) / skill_name
shutil.copytree(skill_dir, staging_target)
_move_staged_skill_into_reserved_target(staging_target, target)
logger.info("Skill %r installed to %s", skill_name, target)
return {
"success": True,
"skill_name": skill_name,
"message": f"Skill '{skill_name}' installed successfully",
}
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
self.validate_skill_name(name)
self.ensure_custom_skill_is_editable(name)
target = self.get_custom_skill_dir(name)
if history_meta is not None:
prev_content = self.read_custom_skill(name)
try:
self.append_history(name, {**history_meta, "prev_content": prev_content})
except OSError as e:
if not isinstance(e, PermissionError) and e.errno not in {errno.EACCES, errno.EPERM, errno.EROFS}:
raise
logger.warning(
"Skipping delete history write for custom skill %s due to readonly/permission failure; continuing with skill directory removal: %s",
name,
e,
)
if target.exists():
shutil.rmtree(target)
def append_history(self, name: str, record: dict) -> None:
self.validate_skill_name(name)
payload = {"ts": datetime.now(UTC).isoformat(), **record}
history_path = self.get_skill_history_file(name)
history_path.parent.mkdir(parents=True, exist_ok=True)
with history_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload, ensure_ascii=False))
f.write("\n")
def read_history(self, name: str) -> list[dict]:
self.validate_skill_name(name)
history_path = self.get_skill_history_file(name)
if not history_path.exists():
return []
records: list[dict] = []
for line in history_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
records.append(json.loads(line))
return records
@@ -0,0 +1,254 @@
"""Abstract SkillStorage base class with template-method flows."""
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from collections.abc import Iterable
from pathlib import Path
from deerflow.skills.types import SKILL_MD_FILE, Skill, SkillCategory # noqa: F401
logger = logging.getLogger(__name__)
_SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class SkillStorage(ABC):
"""Abstract base for skill storage backends.
Subclasses implement a small set of storage-medium-specific atomic
operations; this base class provides final template-method flows
(load_skills, history serialisation, path helpers, validation) that
compose them with protocol-level helpers.
"""
def __init__(self, container_path: str = "/mnt/skills") -> None:
self._container_root = container_path
# ------------------------------------------------------------------
# Static protocol helpers (not storage-specific)
# ------------------------------------------------------------------
@staticmethod
def validate_skill_name(name: str) -> str:
"""Validate and normalise a skill name; return the normalised form."""
normalized = name.strip()
if not _SKILL_NAME_PATTERN.fullmatch(normalized):
raise ValueError("Skill name must be hyphen-case using lowercase letters, digits, and hyphens only.")
if len(normalized) > 64:
raise ValueError("Skill name must be 64 characters or fewer.")
return normalized
@staticmethod
def validate_relative_path(relative_path: str, base_dir: Path) -> Path:
"""Validate *relative_path* against *base_dir* and return the resolved target.
Checks that *relative_path* is non-empty, then joins it with *base_dir*
and resolves the result (following symlinks). Raises ``ValueError`` if
the resolved target does not lie within *base_dir*.
"""
if not relative_path:
raise ValueError("relative_path must not be empty.")
resolved_base = base_dir.resolve()
target = (resolved_base / relative_path).resolve()
try:
target.relative_to(resolved_base)
except ValueError as exc:
raise ValueError("relative_path must resolve within the skill directory.") from exc
return target
@staticmethod
def validate_skill_markdown_content(name: str, content: str) -> None:
"""Validate SKILL.md content: parse frontmatter and check name matches."""
import tempfile
from deerflow.skills.validation import _validate_skill_frontmatter
with tempfile.TemporaryDirectory() as tmp_dir:
temp_skill_dir = Path(tmp_dir) / SkillStorage.validate_skill_name(name)
temp_skill_dir.mkdir(parents=True, exist_ok=True)
(temp_skill_dir / SKILL_MD_FILE).write_text(content, encoding="utf-8")
is_valid, message, parsed_name = _validate_skill_frontmatter(temp_skill_dir)
if not is_valid:
raise ValueError(message)
if parsed_name != name:
raise ValueError(f"Frontmatter name '{parsed_name}' must match requested skill name '{name}'.")
def ensure_safe_support_path(self, name: str, relative_path: str) -> Path:
"""Validate and return the resolved absolute path for a support file."""
_ALLOWED_SUPPORT_SUBDIRS = {"references", "templates", "scripts", "assets"}
skill_dir = self.get_custom_skill_dir(self.validate_skill_name(name)).resolve()
if not relative_path or relative_path.endswith("/"):
raise ValueError("Supporting file path must include a filename.")
relative = Path(relative_path)
if relative.is_absolute():
raise ValueError("Supporting file path must be relative.")
if any(part in {"..", ""} for part in relative.parts):
raise ValueError("Supporting file path must not contain parent-directory traversal.")
top_level = relative.parts[0] if relative.parts else ""
if top_level not in _ALLOWED_SUPPORT_SUBDIRS:
raise ValueError(f"Supporting files must live under one of: {', '.join(sorted(_ALLOWED_SUPPORT_SUBDIRS))}.")
target = (skill_dir / relative).resolve()
allowed_root = (skill_dir / top_level).resolve()
try:
target.relative_to(allowed_root)
except ValueError as exc:
raise ValueError("Supporting file path must stay within the selected support directory.") from exc
return target
# ------------------------------------------------------------------
# Abstract atomic operations (storage-medium specific)
# ------------------------------------------------------------------
@abstractmethod
def get_skills_root_path(self) -> Path:
"""Absolute host path to the skills root, used for sandbox mounts.
Origin: ``deerflow.skills.loader.get_skills_root_path``.
"""
@abstractmethod
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
"""Yield ``(category, category_root, skill_md_path)`` for every SKILL.md.
Origin: extracted from directory-walk logic inside
``deerflow.skills.loader.load_skills``.
"""
@abstractmethod
def read_custom_skill(self, name: str) -> str:
"""Read SKILL.md content for a custom skill.
Origin: ``deerflow.skills.manager.read_custom_skill_content``.
"""
@abstractmethod
def write_custom_skill(self, name: str, relative_path: str, content: str) -> None:
"""Atomically write a text file under ``custom/<name>/<relative_path>``.
Origin: ``deerflow.skills.manager.atomic_write``.
"""
@abstractmethod
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
"""Async install of a skill from a ``.skill`` ZIP archive.
Origin: ``deerflow.skills.installer.ainstall_skill_from_archive``.
"""
def install_skill_from_archive(self, archive_path: str | Path) -> dict:
"""Sync wrapper — delegates to :meth:`ainstall_skill_from_archive`."""
from deerflow.skills.installer import _run_async_install
return _run_async_install(self.ainstall_skill_from_archive(archive_path))
@abstractmethod
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
"""Delete a custom skill (validation + optional history + directory removal).
Origin: ``app.gateway.routers.skills.delete_custom_skill`` + ``skill_manage_tool``.
"""
@abstractmethod
def custom_skill_exists(self, name: str) -> bool:
"""Origin: ``deerflow.skills.manager.custom_skill_exists``."""
@abstractmethod
def public_skill_exists(self, name: str) -> bool:
"""Origin: ``deerflow.skills.manager.public_skill_exists``."""
@abstractmethod
def append_history(self, name: str, record: dict) -> None:
"""Append a JSONL history entry for ``name``.
Origin: ``deerflow.skills.manager.append_history``.
"""
@abstractmethod
def read_history(self, name: str) -> list[dict]:
"""Return all history records for ``name``, oldest first.
Origin: ``deerflow.skills.manager.read_history``.
"""
# ------------------------------------------------------------------
# Concrete path helpers (layout is part of the SKILL.md protocol)
# ------------------------------------------------------------------
def get_container_root(self) -> str:
"""Origin: ``deerflow.config.skills_config.SkillsConfig.container_path`` accessor."""
return self._container_root
def get_custom_skill_dir(self, name: str) -> Path:
"""Path to ``custom/<name>``. Does not create the directory.
Origin: ``deerflow.skills.manager.get_custom_skill_dir``.
"""
normalized_name = self.validate_skill_name(name)
return self.get_skills_root_path() / SkillCategory.CUSTOM.value / normalized_name
def get_custom_skill_file(self, name: str) -> Path:
"""Path to ``custom/<name>/SKILL.md``.
Origin: ``deerflow.skills.manager.get_custom_skill_file``.
"""
normalized_name = self.validate_skill_name(name)
return self.get_custom_skill_dir(normalized_name) / SKILL_MD_FILE
def get_skill_history_file(self, name: str) -> Path:
"""Path to ``custom/.history/<name>.jsonl``. Does not create parents.
Origin: ``deerflow.skills.manager.get_skill_history_file``.
"""
normalized_name = self.validate_skill_name(name)
return self.get_skills_root_path() / SkillCategory.CUSTOM.value / ".history" / f"{normalized_name}.jsonl"
# ------------------------------------------------------------------
# Final template-method flows
# ------------------------------------------------------------------
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
"""Discover all skills, merge enabled state, sort and optionally filter.
Origin: ``deerflow.skills.loader.load_skills``.
"""
from deerflow.skills.parser import parse_skill_file
skills_by_name: dict[str, Skill] = {}
for category, category_root, md_path in self._iter_skill_files():
skill = parse_skill_file(
md_path,
category=category,
relative_path=md_path.parent.relative_to(category_root),
)
if skill:
skills_by_name[skill.name] = skill
skills = list(skills_by_name.values())
# Merge enabled state from extensions config (re-read every call so
# changes made by another process are picked up immediately).
try:
from deerflow.config.extensions_config import ExtensionsConfig
extensions_config = ExtensionsConfig.from_file()
for skill in skills:
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
except Exception as e:
logger.warning("Failed to load extensions config: %s", e)
if enabled_only:
skills = [s for s in skills if s.enabled]
skills.sort(key=lambda s: s.name)
return skills
def ensure_custom_skill_is_editable(self, name: str) -> None:
"""Origin: ``deerflow.skills.manager.ensure_custom_skill_is_editable``."""
if self.custom_skill_exists(name):
return
if self.public_skill_exists(name):
raise ValueError(f"'{name}' is a built-in skill. To customise it, create a new skill with the same name under skills/custom/.")
raise FileNotFoundError(f"Custom skill '{name}' not found.")
@@ -1,6 +1,20 @@
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
SKILL_MD_FILE = "SKILL.md"
class SkillCategory(StrEnum):
"""Source category for a skill.
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
- ``CUSTOM``: user-authored skill that can be edited or deleted.
"""
PUBLIC = "public"
CUSTOM = "custom"
@dataclass
class Skill:
@@ -12,7 +26,7 @@ class Skill:
skill_dir: Path
skill_file: Path
relative_path: Path # Relative path from category root to skill directory
category: str # 'public' or 'custom'
category: SkillCategory # 'public' or 'custom'
enabled: bool = False # Whether this skill is enabled
@property
@@ -8,6 +8,8 @@ from pathlib import Path
import yaml
from deerflow.skills.types import SKILL_MD_FILE
# Allowed properties in SKILL.md frontmatter
ALLOWED_FRONTMATTER_PROPERTIES = {"name", "description", "license", "allowed-tools", "metadata", "compatibility", "version", "author"}
@@ -21,9 +23,9 @@ def _validate_skill_frontmatter(skill_dir: Path) -> tuple[bool, str, str | None]
Returns:
Tuple of (is_valid, message, skill_name).
"""
skill_md = skill_dir / "SKILL.md"
skill_md = skill_dir / SKILL_MD_FILE
if not skill_md.exists():
return False, "SKILL.md not found", None
return False, f"{SKILL_MD_FILE} not found", None
content = skill_md.read_text(encoding="utf-8")
if not content.startswith("---"):
@@ -300,10 +300,10 @@ class SubagentExecutor:
return []
try:
from deerflow.skills.loader import load_skills
from deerflow.skills.storage import get_or_new_skill_storage
# Use asyncio.to_thread to avoid blocking the event loop (LangGraph ASGI requirement)
all_skills = await asyncio.to_thread(load_skills, enabled_only=True)
all_skills = await asyncio.to_thread(get_or_new_skill_storage().load_skills, enabled_only=True)
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk")
except Exception:
logger.warning(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}", exc_info=True)
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import logging
import shutil
from typing import Any
from weakref import WeakValueDictionary
@@ -14,20 +13,10 @@ from langgraph.typing import ContextT
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.agents.thread_state import ThreadState
from deerflow.mcp.tools import _make_sync_tool_wrapper
from deerflow.skills.manager import (
append_history,
atomic_write,
custom_skill_exists,
ensure_custom_skill_is_editable,
ensure_safe_support_path,
get_custom_skill_dir,
get_custom_skill_file,
public_skill_exists,
read_custom_skill_content,
validate_skill_markdown_content,
validate_skill_name,
)
from deerflow.skills.security_scanner import scan_skill_content
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.storage.skill_storage import SkillStorage
from deerflow.skills.types import SKILL_MD_FILE
logger = logging.getLogger(__name__)
@@ -96,50 +85,50 @@ async def _skill_manage_impl(
replace: Replacement text for patch.
expected_count: Optional expected number of replacements for patch.
"""
name = validate_skill_name(name)
name = SkillStorage.validate_skill_name(name)
lock = _get_lock(name)
thread_id = _get_thread_id(runtime)
skill_storage = get_or_new_skill_storage()
async with lock:
if action == "create":
if await _to_thread(custom_skill_exists, name):
if await _to_thread(skill_storage.custom_skill_exists, name):
raise ValueError(f"Custom skill '{name}' already exists.")
if content is None:
raise ValueError("content is required for create.")
await _to_thread(validate_skill_markdown_content, name, content)
scan = await _scan_or_raise(content, executable=False, location=f"{name}/SKILL.md")
skill_file = await _to_thread(get_custom_skill_file, name)
await _to_thread(atomic_write, skill_file, content)
await _to_thread(skill_storage.validate_skill_markdown_content, name, content)
scan = await _scan_or_raise(content, executable=False, location=f"{name}/{SKILL_MD_FILE}")
await _to_thread(skill_storage.write_custom_skill, name, SKILL_MD_FILE, content)
await _to_thread(
append_history,
skill_storage.append_history,
name,
_history_record(action="create", file_path="SKILL.md", prev_content=None, new_content=content, thread_id=thread_id, scanner=scan),
_history_record(action="create", file_path=SKILL_MD_FILE, prev_content=None, new_content=content, thread_id=thread_id, scanner=scan),
)
await refresh_skills_system_prompt_cache_async()
return f"Created custom skill '{name}'."
if action == "edit":
await _to_thread(ensure_custom_skill_is_editable, name)
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
if content is None:
raise ValueError("content is required for edit.")
await _to_thread(validate_skill_markdown_content, name, content)
scan = await _scan_or_raise(content, executable=False, location=f"{name}/SKILL.md")
skill_file = await _to_thread(get_custom_skill_file, name)
await _to_thread(skill_storage.validate_skill_markdown_content, name, content)
scan = await _scan_or_raise(content, executable=False, location=f"{name}/{SKILL_MD_FILE}")
skill_file = skill_storage.get_custom_skill_file(name)
prev_content = await _to_thread(skill_file.read_text, encoding="utf-8")
await _to_thread(atomic_write, skill_file, content)
await _to_thread(skill_storage.write_custom_skill, name, SKILL_MD_FILE, content)
await _to_thread(
append_history,
skill_storage.append_history,
name,
_history_record(action="edit", file_path="SKILL.md", prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan),
_history_record(action="edit", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan),
)
await refresh_skills_system_prompt_cache_async()
return f"Updated custom skill '{name}'."
if action == "patch":
await _to_thread(ensure_custom_skill_is_editable, name)
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
if find is None or replace is None:
raise ValueError("find and replace are required for patch.")
skill_file = await _to_thread(get_custom_skill_file, name)
skill_file = skill_storage.get_custom_skill_file(name)
prev_content = await _to_thread(skill_file.read_text, encoding="utf-8")
occurrences = prev_content.count(find)
if occurrences == 0:
@@ -148,64 +137,67 @@ async def _skill_manage_impl(
raise ValueError(f"Expected {expected_count} replacements but found {occurrences}.")
replacement_count = expected_count if expected_count is not None else 1
new_content = prev_content.replace(find, replace, replacement_count)
await _to_thread(validate_skill_markdown_content, name, new_content)
scan = await _scan_or_raise(new_content, executable=False, location=f"{name}/SKILL.md")
await _to_thread(atomic_write, skill_file, new_content)
await _to_thread(skill_storage.validate_skill_markdown_content, name, new_content)
scan = await _scan_or_raise(new_content, executable=False, location=f"{name}/{SKILL_MD_FILE}")
await _to_thread(skill_storage.write_custom_skill, name, SKILL_MD_FILE, new_content)
await _to_thread(
append_history,
skill_storage.append_history,
name,
_history_record(action="patch", file_path="SKILL.md", prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan),
_history_record(action="patch", file_path=SKILL_MD_FILE, prev_content=prev_content, new_content=new_content, thread_id=thread_id, scanner=scan),
)
await refresh_skills_system_prompt_cache_async()
return f"Patched custom skill '{name}' ({replacement_count} replacement(s) applied, {occurrences} match(es) found)."
if action == "delete":
await _to_thread(ensure_custom_skill_is_editable, name)
skill_dir = await _to_thread(get_custom_skill_dir, name)
prev_content = await _to_thread(read_custom_skill_content, name)
await _to_thread(
append_history,
skill_storage.delete_custom_skill,
name,
_history_record(action="delete", file_path="SKILL.md", prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}),
history_meta=_history_record(
action="delete",
file_path=SKILL_MD_FILE,
prev_content=None,
new_content=None,
thread_id=thread_id,
scanner={"decision": "allow", "reason": "Deletion requested."},
),
)
await _to_thread(shutil.rmtree, skill_dir)
await refresh_skills_system_prompt_cache_async()
return f"Deleted custom skill '{name}'."
if action == "write_file":
await _to_thread(ensure_custom_skill_is_editable, name)
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
if path is None or content is None:
raise ValueError("path and content are required for write_file.")
target = await _to_thread(ensure_safe_support_path, name, path)
target = await _to_thread(skill_storage.ensure_safe_support_path, name, path)
exists = await _to_thread(target.exists)
prev_content = await _to_thread(target.read_text, encoding="utf-8") if exists else None
executable = "scripts/" in path or path.startswith("scripts/")
scan = await _scan_or_raise(content, executable=executable, location=f"{name}/{path}")
await _to_thread(atomic_write, target, content)
await _to_thread(skill_storage.write_custom_skill, name, path, content)
await _to_thread(
append_history,
skill_storage.append_history,
name,
_history_record(action="write_file", file_path=path, prev_content=prev_content, new_content=content, thread_id=thread_id, scanner=scan),
)
return f"Wrote '{path}' for custom skill '{name}'."
if action == "remove_file":
await _to_thread(ensure_custom_skill_is_editable, name)
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
if path is None:
raise ValueError("path is required for remove_file.")
target = await _to_thread(ensure_safe_support_path, name, path)
target = await _to_thread(skill_storage.ensure_safe_support_path, name, path)
if not await _to_thread(target.exists):
raise FileNotFoundError(f"Supporting file '{path}' not found for skill '{name}'.")
prev_content = await _to_thread(target.read_text, encoding="utf-8")
await _to_thread(target.unlink)
await _to_thread(
append_history,
skill_storage.append_history,
name,
_history_record(action="remove_file", file_path=path, prev_content=prev_content, new_content=None, thread_id=thread_id, scanner={"decision": "allow", "reason": "Deletion requested."}),
)
return f"Removed '{path}' from custom skill '{name}'."
if await _to_thread(public_skill_exists, name):
if await _to_thread(skill_storage.public_skill_exists, name):
raise ValueError(f"'{name}' is a built-in skill. To customise it, create a new skill with the same name under skills/custom/.")
raise ValueError(f"Unsupported action '{action}'.")