fischer-agentkit/src/agentkit/tools/skill_install.py

224 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SkillInstallTool - Agent 可调用的技能安装工具"""
import asyncio
import logging
import os
import re
from typing import Callable, Awaitable
from agentkit.tools.base import Tool
logger = logging.getLogger(__name__)
# Skill names are used to construct filesystem paths — reject anything
# that could escape the skills directory (path traversal).
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
class SkillInstallTool(Tool):
"""技能安装工具
让 Agent 可以通过正确的命令安装技能,而不是用 shell 执行 npm install。
使用 `npx skills install <owner/repo@skill>` 安装技能,然后注册到 skill_registry。
Usage:
tool = SkillInstallTool()
result = await tool.execute(name="find-skills", source="vercel-labs/skills@find-skills")
"""
def __init__(
self,
name: str = "skill_install",
description: str = (
"安装 Agent 技能包。使用 npx skills install 安装指定技能,不要用 npm install。"
"重要:安装前应先用 skill_search 工具搜索确认技能名称和来源(source)。"
"如果用户只提供了模糊名称,先用 skill_search 搜索,再根据搜索结果安装。"
),
input_schema: dict[str, object] | None = None,
output_schema: dict[str, object] | None = None,
version: str = "1.0.0",
tags: list[str] | None = None,
confirm_callback: Callable[[str], Awaitable[bool]] | None = None,
skill_registry=None,
tool_registry=None,
):
schema = input_schema or {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "技能名称,如 find-skills",
},
"source": {
"type": "string",
"description": "技能来源,格式为 owner/repo@skill如 vercel-labs/skills@find-skills。如果不提供则用 name 搜索",
},
},
"required": ["name"],
}
super().__init__(
name=name,
description=description,
input_schema=schema,
output_schema=output_schema,
version=version,
tags=tags,
)
self._confirm_callback = confirm_callback
self._skill_registry = skill_registry
self._tool_registry = tool_registry
async def execute(self, **kwargs) -> dict:
name = kwargs.get("name", "").strip()
source = kwargs.get("source", "").strip()
if not name:
return {
"output": "错误: 必须提供 name 参数",
"exit_code": 1,
"is_error": True,
}
# Validate name — it's used to construct filesystem paths in
# _try_register_skill, so reject anything that could escape the
# skills directory (e.g. ``../../etc/passwd``).
if not _SAFE_NAME_RE.match(name):
return {
"output": f"错误: 技能名称包含非法字符: {name}(仅允许字母、数字、下划线、连字符)",
"exit_code": 1,
"is_error": True,
}
# Build the install command
if source:
install_target = source
else:
install_target = name
# Request confirmation before installing
if self._confirm_callback:
confirmed = await self._confirm_callback(f"npx skills install {install_target}")
if not confirmed:
return {
"output": f"技能安装被用户拒绝: {install_target}",
"exit_code": 126,
"is_error": True,
}
try:
# ``--yes`` is an npx flag (NOT a skills flag) that auto-accepts
# the "Need to install the following packages: skills@x.y.z.
# Ok to proceed?" prompt. Without it, npx blocks waiting for
# stdin in the non-interactive subprocess and the install fails
# with "npm error canceled". The trailing ``-y`` is the skills
# CLI's own confirm flag.
proc = await asyncio.create_subprocess_exec(
"npx",
"--yes",
"skills@latest",
"install",
install_target,
"-y",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
output = stdout.decode("utf-8", errors="replace")
error_output = stderr.decode("utf-8", errors="replace")
if proc.returncode == 0:
# Try to register the skill into skill_registry
registration_msg = ""
if self._skill_registry:
registration_msg = self._try_register_skill(name)
return {
"output": f"技能 {name} 安装成功\n{output}\n{registration_msg}",
"exit_code": 0,
"is_error": False,
}
else:
return {
"output": f"技能 {name} 安装失败 (exit={proc.returncode})\n{error_output}\n{output}",
"exit_code": proc.returncode,
"is_error": True,
"suggestions": [
"检查技能名称是否正确",
"使用 source 参数指定完整路径,如 vercel-labs/skills@find-skills",
"运行 npx skills search <name> 搜索可用技能",
],
}
except asyncio.TimeoutError:
return {
"output": f"技能 {name} 安装超时120s",
"exit_code": -1,
"is_error": True,
}
except Exception as e:
return {
"output": f"技能 {name} 安装异常: {e}",
"exit_code": -1,
"is_error": True,
}
def _try_register_skill(self, name: str) -> str:
"""Try to find and register the installed skill YAML/SKILL.md into skill_registry.
``npx skills add`` installs skills as ``{skills_dir}/{name}/SKILL.md``
(a directory containing a markdown-frontmatter file), NOT as a
flat ``{name}.yaml``. This method therefore checks both layouts:
1. Flat YAML: ``{skills_dir}/{name}.yaml`` (legacy / hand-authored)
2. Directory with SKILL.md: ``{skills_dir}/{name}/SKILL.md`` (npx skills)
3. Directory with any .yaml/.yml: ``{skills_dir}/{name}/*.yaml`` (fallback)
"""
try:
from agentkit.skills.loader import SkillLoader
search_dirs = [
os.path.join(os.getcwd(), ".agents", "skills"),
os.path.join(os.path.expanduser("~"), ".agents", "skills"),
os.path.join(os.getcwd(), "configs", "skills"),
]
for search_dir in search_dirs:
# Layout 1: flat {name}.yaml
yaml_path = os.path.join(search_dir, f"{name}.yaml")
if os.path.exists(yaml_path):
loader = SkillLoader(
skill_registry=self._skill_registry,
tool_registry=self._tool_registry,
)
loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}"
# Layout 2 & 3: directory-based skills ({name}/SKILL.md or {name}/*.yaml)
for search_dir in search_dirs:
skill_dir = os.path.join(search_dir, name)
if not os.path.isdir(skill_dir):
continue
# Prefer SKILL.md (the format npx skills actually produces)
md_path = os.path.join(skill_dir, "SKILL.md")
if os.path.isfile(md_path):
loader = SkillLoader(
skill_registry=self._skill_registry,
tool_registry=self._tool_registry,
)
loader.load_from_skill_md(md_path)
return f"技能已注册到系统(来源: {md_path}"
# Fallback: any YAML file inside the directory
for fname in sorted(os.listdir(skill_dir)):
if fname.endswith((".yaml", ".yml")):
yaml_path = os.path.join(skill_dir, fname)
loader = SkillLoader(
skill_registry=self._skill_registry,
tool_registry=self._tool_registry,
)
loader.load_from_file(yaml_path)
return f"技能已注册到系统(来源: {yaml_path}"
return "技能文件已下载,但未找到 YAML/SKILL.md 配置文件进行注册。可能需要重启服务。"
except Exception as e:
logger.warning(f"Failed to register skill {name}: {e}")
return f"技能文件已下载,但注册失败: {e}"