Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.skills.skill_manager import (
SkillInfo,
SkillManager,
build_skills_prompt,
)
from astrbot.core.star.context import Context
from astrbot.core.star.star import star_registry
from astrbot.core.star.star_handler import star_map
from astrbot.core.tools.computer_tools import (
AnnotateExecutionTool,
Expand Down Expand Up @@ -375,6 +380,38 @@ def _build_local_mode_prompt() -> str:
)


def _filter_skills_for_current_config(
skills: list[SkillInfo],
cfg: dict,
) -> list[SkillInfo]:
plugin_set = cfg.get("plugin_set", ["*"])
allowed_plugins = (
None
if not isinstance(plugin_set, list) or "*" in plugin_set
else {str(name) for name in plugin_set}
)
plugin_by_root_dir = {
metadata.root_dir_name: metadata
for metadata in star_registry
if metadata.root_dir_name
}
filtered: list[SkillInfo] = []
for skill in skills:
if skill.source_type != "plugin":
filtered.append(skill)
continue

plugin = plugin_by_root_dir.get(skill.plugin_name)
if not plugin or not plugin.activated:
continue
if plugin.reserved or allowed_plugins is None:
filtered.append(skill)
continue
if plugin.name is not None and plugin.name in allowed_plugins:
filtered.append(skill)
return filtered


async def _ensure_persona_and_skills(
req: ProviderRequest,
cfg: dict,
Expand Down Expand Up @@ -417,6 +454,7 @@ async def _ensure_persona_and_skills(
runtime = cfg.get("computer_use_runtime", "local")
skill_manager = SkillManager()
skills = skill_manager.list_skills(active_only=True, runtime=runtime)
skills = _filter_skills_for_current_config(skills, cfg)

if skills:
if persona and persona.get("skills") is not None:
Expand Down
61 changes: 53 additions & 8 deletions astrbot/core/computer/computer_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
return skills


def _collect_sync_skill_dirs() -> list[tuple[str, Path]]:
"""Collect local and plugin-provided skills that should be synced."""
skills_root = Path(get_astrbot_skills_path())
if not skills_root.is_dir():
return []

try:
skill_manager = SkillManager(skills_root=str(skills_root))
except OSError as exc:
logger.warning("[Computer] Failed to initialize skill manager: %s", exc)
return []

sync_dirs: list[tuple[str, Path]] = []
for skill in skill_manager.list_skills(
active_only=False,
runtime="local",
show_sandbox_path=False,
):
if skill.source_type == "sandbox_only":
continue
skill_md = Path(skill.path)
if not skill_md.is_file():
continue
sync_dirs.append((skill.name, skill_md.parent))
return sync_dirs


def _normalize_shell_exec_result(result: object) -> dict:
if isinstance(result, dict):
return result
return {"exit_code": 0, "stdout": "", "stderr": ""}


def _discover_bay_credentials(endpoint: str) -> str:
"""Try to auto-discover Bay API key from credentials.json.

Expand Down Expand Up @@ -351,7 +384,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
executed in a separate phase to keep failure domains clear.
"""
logger.info("[Computer] Skill sync phase=apply start")
apply_result = await booter.shell.exec(_build_apply_sync_command())
apply_result = _normalize_shell_exec_result(
await booter.shell.exec(_build_apply_sync_command())
)
if not _shell_exec_succeeded(apply_result):
detail = _format_exec_error_detail(apply_result)
logger.error("[Computer] Skill sync phase=apply failed: %s", detail)
Expand All @@ -362,7 +397,9 @@ async def _apply_skills_to_sandbox(booter: ComputerBooter) -> None:
async def _scan_sandbox_skills(booter: ComputerBooter) -> dict | None:
"""Scan sandbox skills and return normalized payload for cache update."""
logger.info("[Computer] Skill sync phase=scan start")
scan_result = await booter.shell.exec(_build_scan_command())
scan_result = _normalize_shell_exec_result(
await booter.shell.exec(_build_scan_command())
)
if not _shell_exec_succeeded(scan_result):
detail = _format_exec_error_detail(scan_result)
logger.error("[Computer] Skill sync phase=scan failed: %s", detail)
Expand All @@ -382,21 +419,24 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
Backward-compatible orchestrator: keep historical behavior while internally
splitting into `apply` and `scan` phases.
"""
skills_root = Path(get_astrbot_skills_path())
if not skills_root.is_dir():
return
local_skill_dirs = _list_local_skill_dirs(skills_root)
sync_skill_dirs = _collect_sync_skill_dirs()

temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
zip_base = temp_dir / "skills_bundle"
zip_path = zip_base.with_suffix(".zip")
bundle_root = temp_dir / f"skills_bundle_{uuid.uuid4().hex}"

try:
if local_skill_dirs:
if sync_skill_dirs:
if zip_path.exists():
zip_path.unlink()
shutil.make_archive(str(zip_base), "zip", str(skills_root))
if bundle_root.exists():
shutil.rmtree(bundle_root)
bundle_root.mkdir(parents=True)
for skill_name, skill_dir in sync_skill_dirs:
shutil.copytree(skill_dir, bundle_root / skill_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using shutil.copytree to create an intermediate directory structure before zipping is inefficient as it performs redundant disk I/O and increases temporary storage requirements. For a large number of skills or large assets, this can be slow and block the event loop. Consider using the zipfile module to add files directly to the archive from their original locations. Additionally, ensure this new functionality is accompanied by corresponding unit tests.

References
  1. New functionality, such as handling attachments, should be accompanied by corresponding unit tests.

shutil.make_archive(str(zip_base), "zip", str(bundle_root))
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
logger.info("Uploading skills bundle to sandbox...")
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
Expand All @@ -420,6 +460,11 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
len(managed),
)
finally:
if bundle_root.exists():
try:
shutil.rmtree(bundle_root)
except Exception:
logger.warning(f"Failed to remove temp skills bundle: {bundle_root}")
if zip_path.exists():
try:
zip_path.unlink()
Expand Down
107 changes: 105 additions & 2 deletions astrbot/core/skills/skill_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_plugin_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
Expand Down Expand Up @@ -64,7 +65,11 @@ def _is_ignored_zip_entry(name: str) -> bool:
return parts[0] == "__MACOSX"


def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None:
def _normalize_skill_markdown_path(
skill_dir: Path,
*,
rename_legacy: bool = True,
) -> Path | None:
"""Return the canonical `SKILL.md` path for a skill directory.

If only legacy `skill.md` exists, it is renamed to `SKILL.md` in-place.
Expand All @@ -79,6 +84,8 @@ def _normalize_skill_markdown_path(skill_dir: Path) -> Path | None:
if "skill.md" not in entries:
return None
try:
if not rename_legacy:
return legacy
tmp = skill_dir / f".{uuid.uuid4().hex}.tmp_skill_md"
legacy.rename(tmp)
tmp.rename(canonical)
Expand All @@ -97,6 +104,8 @@ class SkillInfo:
source_label: str = "local"
local_exists: bool = True
sandbox_exists: bool = False
plugin_name: str = ""
readonly: bool = False


def _parse_frontmatter_description(text: str) -> str:
Expand Down Expand Up @@ -274,13 +283,60 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:


class SkillManager:
def __init__(self, skills_root: str | None = None) -> None:
def __init__(
self,
skills_root: str | None = None,
plugins_root: str | None = None,
) -> None:
self.skills_root = skills_root or get_astrbot_skills_path()
self.plugins_root = plugins_root or get_astrbot_plugin_path()
data_path = Path(get_astrbot_data_path())
self.config_path = str(data_path / SKILLS_CONFIG_FILENAME)
self.sandbox_skills_cache_path = str(data_path / SANDBOX_SKILLS_CACHE_FILENAME)
os.makedirs(self.skills_root, exist_ok=True)

def _iter_plugin_skill_dirs(self) -> list[tuple[str, str, Path]]:
"""Return plugin-provided skill directories as (skill, plugin, dir)."""
plugins_root = Path(self.plugins_root)
if not plugins_root.is_dir():
return []

result: list[tuple[str, str, Path]] = []
for plugin_dir in sorted(plugins_root.iterdir(), key=lambda item: item.name):
if not plugin_dir.is_dir():
continue
plugin_name = plugin_dir.name
skills_dir = plugin_dir / "skills"
if not skills_dir.is_dir():
continue

direct_skill_md = _normalize_skill_markdown_path(
skills_dir,
rename_legacy=False,
)
if direct_skill_md is not None and _SKILL_NAME_RE.match(plugin_name):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _SKILL_NAME_RE used here (^[\w.-]+$) allows Unicode characters by default in Python 3. However, the corresponding regex in astrbot/dashboard/routes/skills.py (^[A-Za-z0-9._-]+$) is restricted to ASCII. This inconsistency will cause plugin skills with non-ASCII names to be correctly loaded by the core but rejected by the dashboard API. To ensure consistent behavior and avoid code duplication, refactor the logic into a shared helper function or constant.

References
  1. When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.

result.append((plugin_name, plugin_name, skills_dir))

for skill_dir in sorted(skills_dir.iterdir(), key=lambda item: item.name):
if not skill_dir.is_dir():
continue
skill_name = skill_dir.name
if not _SKILL_NAME_RE.match(skill_name):
continue
if (
_normalize_skill_markdown_path(skill_dir, rename_legacy=False)
is None
):
continue
result.append((skill_name, plugin_name, skill_dir))
return result

def _get_plugin_skill_dir(self, name: str) -> Path | None:
for skill_name, _plugin_name, skill_dir in self._iter_plugin_skill_dirs():
if skill_name == name:
return skill_dir
return None

def _load_config(self) -> dict:
if not os.path.exists(self.config_path):
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
Expand Down Expand Up @@ -430,6 +486,46 @@ def list_skills(
sandbox_exists=sandbox_exists,
)

for skill_name, plugin_name, skill_dir in self._iter_plugin_skill_dirs():
if skill_name in skills_by_name:
continue
skill_md = _normalize_skill_markdown_path(skill_dir, rename_legacy=False)
if skill_md is None:
continue
active = skill_configs.get(skill_name, {}).get("active", True)
if skill_name not in skill_configs:
skill_configs[skill_name] = {"active": active}
modified = True
if active_only and not active:
continue
description = ""
try:
content = skill_md.read_text(encoding="utf-8")
description = _parse_frontmatter_description(content)
except Exception:
description = ""
sandbox_exists = (
runtime == "sandbox" and skill_name in sandbox_cached_descriptions
)
if runtime == "sandbox" and show_sandbox_path:
path_str = sandbox_cached_paths.get(
skill_name
) or _default_sandbox_skill_path(skill_name)
else:
path_str = str(skill_md)
skills_by_name[skill_name] = SkillInfo(
name=skill_name,
description=description,
path=path_str.replace("\\", "/"),
active=active,
source_type="plugin",
source_label=plugin_name,
local_exists=True,
sandbox_exists=sandbox_exists,
plugin_name=plugin_name,
readonly=True,
)

if runtime == "sandbox":
cache = self._load_sandbox_skills_cache()
for item in cache.get("skills", []):
Expand Down Expand Up @@ -488,6 +584,9 @@ def is_sandbox_only_skill(self, name: str) -> bool:
return True
return False

def is_plugin_skill(self, name: str) -> bool:
return self._get_plugin_skill_dir(name) is not None

def set_skill_active(self, name: str, active: bool) -> None:
if self.is_sandbox_only_skill(name):
raise PermissionError(
Expand Down Expand Up @@ -521,6 +620,10 @@ def delete_skill(self, name: str) -> None:
raise PermissionError(
"Sandbox preset skill cannot be deleted from local skill management."
)
if self.is_plugin_skill(name):
raise PermissionError(
"Plugin-provided skill cannot be deleted from local skill management."
)

skill_dir = Path(self.skills_root) / name
if skill_dir.exists():
Expand Down
Loading
Loading