Skip to content
Open
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
11 changes: 11 additions & 0 deletions astrbot/core/skills/skill_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,17 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
"files that are directly linked from `SKILL.md`.\n"
"7. **Failure handling** — If a skill cannot be applied, state the "
"issue clearly and continue with the best alternative.\n"
"8. **Creating new skills** — You can create new skills on behalf "
"of the user:\n"
" - Write a `SKILL.md` file (with YAML frontmatter containing "
"`name` and `description`) using `astrbot_file_write_tool` to "
"`data/skills/<skill_name>/SKILL.md`.\n"
" - The system auto-discovers skills in `data/skills/` on every "
"request — no manual registration needed.\n"
" - For packaging or backup, use `astrbot_create_skill_zip` to "
"create a distributable ZIP.\n"
" - To install from a ZIP (e.g. received from another user), "
"use `astrbot_install_skill_from_zip`.\n"
)


Expand Down
3 changes: 3 additions & 0 deletions astrbot/core/tools/computer_tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,22 @@
RunBrowserSkillTool,
SyncSkillReleaseTool,
)
from .skill_tools import CreateSkillZipTool, InstallSkillFromZipTool
from .util import check_admin_permission, normalize_umo_for_workspace

__all__ = [
"AnnotateExecutionTool",
"BrowserBatchExecTool",
"BrowserExecTool",
"CreateSkillCandidateTool",
"CreateSkillZipTool",
"CreateSkillPayloadTool",
"CuaKeyboardTypeTool",
"CuaMouseClickTool",
"CuaScreenshotTool",
"EvaluateSkillCandidateTool",
"ExecuteShellTool",
"InstallSkillFromZipTool",
"FileDownloadTool",
"FileEditTool",
"FileReadTool",
Expand Down
265 changes: 265 additions & 0 deletions astrbot/core/tools/computer_tools/skill_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Skill self-authoring tools for local runtime.

These tools allow the LLM to create, package, and install skills
in local mode. The existing neo_skills.py tools only work in
shipyard_neo sandbox mode; these tools bridge the gap for local runtime.

Prerequisites for use:
1. The LLM writes SKILL.md (and optional supporting files) to
``data/skills/<skill_name>/`` using ``astrbot_file_write_tool``.
2. The LLM then calls ``create_skill_zip`` to package the directory.
3. The LLM calls ``install_skill_from_zip`` to register the skill.

Alternatively, since ``SkillManager.list_skills()`` auto-discovers any
directory containing SKILL.md under ``data/skills/`` on every request,
steps 2-3 are optional for immediate local use — but are useful for
distribution, backup, or reinstall workflows.
"""

import logging
import os
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path

from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext

from ..registry import builtin_tool
from .util import check_admin_permission, is_local_runtime

logger = logging.getLogger(__name__)

_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
}

_SKILL_NAME_RE = re.compile(r"^[\w.\-]+$")


def _resolve_temp_path(local_env: bool, filename: str) -> Path:
"""Return temp directory path, consistent across local/sandbox runtimes.

Raises ValueError if *filename* would escape the temp directory
(e.g. contains ``..`` components).
"""
# Reject directory-traversal attempts
clean = Path(filename)
if clean.is_absolute() or ".." in clean.parts:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
raise ValueError(f"Invalid filename: {filename!r}")

if local_env:
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path

return Path(get_astrbot_temp_path()) / filename
return Path(f"/tmp/{filename}")
Comment thread
sourcery-ai[bot] marked this conversation as resolved.


def _is_within(path: Path, root: Path) -> bool:
"""Return True if *path* is inside *root* (after resolving both)."""
try:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
path.resolve().relative_to(root.resolve())
return True
except ValueError:
return False


@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class CreateSkillZipTool(FunctionTool):
"""Package a skill directory into a ZIP archive.

The skill directory must already exist under ``data/skills/<skill_name>/``
and contain at least a ``SKILL.md`` file. The resulting ZIP is written
to the temp directory and the path is returned so that
``install_skill_from_zip`` can consume it.
"""

name: str = "astrbot_create_skill_zip"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
description: str = (
"Package an existing skill directory into a ZIP archive for installation "
"or distribution. The skill must already have a SKILL.md file in its directory."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "Name of the skill directory under data/skills/ to package.",
},
"overwrite": {
"type": "boolean",
"description": "Overwrite existing zip file if it exists. Defaults to false.",
"default": False,
},
},
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"required": ["skill_name"],
}
)

async def call(
self,
context: ContextWrapper[AstrAgentContext],
skill_name: str,
overwrite: bool = False,
) -> ToolExecResult:
if err := check_admin_permission(context, "Skill zip creation"):
return err

if not skill_name or not _SKILL_NAME_RE.fullmatch(skill_name):
return "Error: Invalid skill name. Use only alphanumeric characters, dots, hyphens, and underscores."

local_env = is_local_runtime(context)

try:
from astrbot.core.skills.skill_manager import (
_normalize_skill_markdown_path,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
)

skills_root = get_astrbot_skills_path()
skill_dir = Path(skills_root) / skill_name

if not skill_dir.exists() or not skill_dir.is_dir():
return f"Error: Skill directory not found: {skill_dir}"

skill_md = _normalize_skill_markdown_path(skill_dir)
if skill_md is None:
return f"Error: No SKILL.md found in {skill_dir}"

try:
zip_path = _resolve_temp_path(local_env, f"{skill_name}.zip")
except ValueError as ve:
return f"Error: {ve}"
zip_path.parent.mkdir(parents=True, exist_ok=True)

if zip_path.exists() and not overwrite:
return (
f"Error: Zip file already exists at {zip_path}. "
"Set overwrite=true to replace it."
)

# Pack the skill directory into a zip
with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
Comment thread
Blueteemo marked this conversation as resolved.
for root, _dirs, files in os.walk(skill_dir):
for file in files:
file_path = Path(root) / file
arcname = Path(skill_name) / file_path.relative_to(skill_dir)
zf.write(str(file_path), str(arcname))

return f"Skill '{skill_name}' packaged successfully: {zip_path}"

except Exception as e:
logger.exception("Error creating skill zip")
return f"Error creating skill zip: {type(e).__name__}: {e}"


@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
@dataclass
class InstallSkillFromZipTool(FunctionTool):
"""Install or update a skill from a ZIP archive.

Wraps ``SkillManager.install_skill_from_zip()`` so the LLM can
Comment thread
Blueteemo marked this conversation as resolved.
install a skill it just packaged (or received from a user).
The ZIP must contain a ``SKILL.md`` at root or inside a top-level
directory.
"""

name: str = "astrbot_install_skill_from_zip"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
description: str = (
"Install or update a skill from a ZIP file. The ZIP should contain "
"a SKILL.md file either at the root or inside a single top-level directory."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"zip_path": {
"type": "string",
"description": (
"Path to the ZIP file. If relative, resolves under "
"the temp directory."
),
},
"skill_name": {
"type": "string",
"description": (
"Optional name override for the installed skill. "
"If omitted, the name is derived from the zip contents."
),
},
"overwrite": {
"type": "boolean",
"description": "Replace existing skill if it exists. Defaults to true.",
"default": True,
},
},
"required": ["zip_path"],
}
)

async def call(
self,
context: ContextWrapper[AstrAgentContext],
zip_path: str,
skill_name: str | None = None,
overwrite: bool = True,
) -> ToolExecResult:
if err := check_admin_permission(context, "Skill installation"):
return err

local_env = is_local_runtime(context)

if skill_name and not _SKILL_NAME_RE.fullmatch(skill_name):
return "Error: Invalid skill name. Use only alphanumeric characters, dots, hyphens, and underscores."

try:
from astrbot.core.skills.skill_manager import SkillManager

# Resolve relative paths under temp dir; reject absolute paths
# that escape allowed directories.
if Path(zip_path).is_absolute():
resolved = Path(zip_path)
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_temp_path,
)

allowed_roots = [
Path(get_astrbot_temp_path()),
Path(get_astrbot_skills_path()),
]
if not local_env:
allowed_roots.append(Path("/tmp"))
if not any(_is_within(resolved, root) for root in allowed_roots):
return (
"Error: Absolute zip_path must be inside the temp or "
"skills directory for security."
)
else:
try:
resolved = _resolve_temp_path(local_env, zip_path)
except ValueError as ve:
return f"Error: {ve}"

if not resolved.exists():
return f"Error: ZIP file not found: {resolved}"

skill_manager = SkillManager()
installed = skill_manager.install_skill_from_zip(
zip_path=str(resolved),
overwrite=overwrite,
skill_name_hint=skill_name,
)

return f"Successfully installed skill(s): {installed}"

except Exception as e:
logger.exception("Error installing skill from zip")
return f"Error installing skill from zip: {type(e).__name__}: {e}"
Loading