mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 00:16:48 +00:00
refactor(skills): Unified skill storage capability (#2613)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}'.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user