- Add agent core modules (agent_core, factory, registry, skill_loader) - Add runtime system for agent execution management - Add REST API for agents, workspaces, and runtime control - Add process supervisor for agent lifecycle management - Add workspace template system with agent profiles - Add frontend RuntimeView and runtime API integration - Add per-agent skill workspaces for smoke_fullstack run - Refactor skill system with active/installed separation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
389 lines
12 KiB
Python
389 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Skill loader for loading and validating skills from directories.
|
||
|
||
提供从目录加载技能、解析SKILL.md frontmatter、获取工具列表等功能。
|
||
"""
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set
|
||
|
||
import yaml
|
||
|
||
from backend.agents.skill_metadata import SkillMetadata, parse_skill_metadata
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class SkillInfo:
|
||
"""完整的技能信息"""
|
||
name: str
|
||
description: str
|
||
version: str
|
||
source: str
|
||
path: Path
|
||
metadata: SkillMetadata
|
||
tools: List[str] = field(default_factory=list)
|
||
scripts: List[str] = field(default_factory=list)
|
||
references: List[str] = field(default_factory=list)
|
||
content: str = ""
|
||
|
||
|
||
def load_skill_from_dir(skill_dir: Path, source: str = "unknown") -> Optional[Dict[str, Any]]:
|
||
"""从目录加载技能
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
source: 技能来源 (builtin/customized/local/installed/active)
|
||
|
||
Returns:
|
||
技能信息字典,加载失败返回None
|
||
"""
|
||
if not skill_dir.exists() or not skill_dir.is_dir():
|
||
logger.warning(f"Skill directory does not exist: {skill_dir}")
|
||
return None
|
||
|
||
skill_md = skill_dir / "SKILL.md"
|
||
if not skill_md.exists():
|
||
logger.warning(f"SKILL.md not found in: {skill_dir}")
|
||
return None
|
||
|
||
try:
|
||
# 解析元数据
|
||
metadata = parse_skill_metadata(skill_dir, source=source)
|
||
|
||
# 读取完整内容
|
||
content = skill_md.read_text(encoding="utf-8")
|
||
|
||
# 提取body (去掉frontmatter)
|
||
body = content
|
||
if content.startswith("---"):
|
||
parts = content.split("---", 2)
|
||
if len(parts) >= 3:
|
||
body = parts[2].strip()
|
||
|
||
# 获取工具列表
|
||
tools = get_skill_tools(skill_dir)
|
||
|
||
# 获取脚本列表
|
||
scripts = _get_skill_scripts(skill_dir)
|
||
|
||
# 获取参考资料列表
|
||
references = _get_skill_references(skill_dir)
|
||
|
||
return {
|
||
"name": metadata.name,
|
||
"skill_name": metadata.skill_name,
|
||
"description": metadata.description,
|
||
"version": metadata.version,
|
||
"source": source,
|
||
"path": str(skill_dir),
|
||
"content": body,
|
||
"tools": tools,
|
||
"scripts": scripts,
|
||
"references": references,
|
||
"metadata": metadata,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Failed to load skill from {skill_dir}: {e}")
|
||
return None
|
||
|
||
|
||
def parse_skill_metadata(skill_dir: Path, source: str = "unknown") -> SkillMetadata:
|
||
"""解析技能元数据 (兼容已有函数)
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
source: 技能来源
|
||
|
||
Returns:
|
||
SkillMetadata对象
|
||
"""
|
||
from backend.agents.skill_metadata import parse_skill_metadata as _parse
|
||
return _parse(skill_dir, source=source)
|
||
|
||
|
||
def get_skill_tools(skill_dir: Path) -> List[str]:
|
||
"""获取技能提供的工具列表
|
||
|
||
从SKILL.md frontmatter的tools字段和scripts目录解析工具。
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
|
||
Returns:
|
||
工具名称列表
|
||
"""
|
||
tools: Set[str] = set()
|
||
|
||
# 1. 从SKILL.md frontmatter读取tools字段
|
||
skill_md = skill_dir / "SKILL.md"
|
||
if skill_md.exists():
|
||
try:
|
||
raw = skill_md.read_text(encoding="utf-8").strip()
|
||
if raw.startswith("---"):
|
||
parts = raw.split("---", 2)
|
||
if len(parts) >= 3:
|
||
try:
|
||
frontmatter = yaml.safe_load(parts[1].strip()) or {}
|
||
if isinstance(frontmatter, dict):
|
||
tools_list = frontmatter.get("tools", [])
|
||
if isinstance(tools_list, str):
|
||
tools.add(tools_list.strip())
|
||
elif isinstance(tools_list, list):
|
||
for tool in tools_list:
|
||
if isinstance(tool, str):
|
||
tools.add(tool.strip())
|
||
except yaml.YAMLError:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"Failed to parse tools from SKILL.md: {e}")
|
||
|
||
# 2. 从scripts目录推断工具
|
||
scripts_dir = skill_dir / "scripts"
|
||
if scripts_dir.exists() and scripts_dir.is_dir():
|
||
for script in scripts_dir.iterdir():
|
||
if script.is_file() and not script.name.startswith("_"):
|
||
# 去掉扩展名作为工具名
|
||
tool_name = script.stem
|
||
tools.add(tool_name)
|
||
|
||
return sorted(list(tools))
|
||
|
||
|
||
def _get_skill_scripts(skill_dir: Path) -> List[str]:
|
||
"""获取技能脚本列表
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
|
||
Returns:
|
||
脚本相对路径列表 (相对于scripts目录)
|
||
"""
|
||
scripts: List[str] = []
|
||
scripts_dir = skill_dir / "scripts"
|
||
|
||
if not scripts_dir.exists():
|
||
return scripts
|
||
|
||
try:
|
||
for item in scripts_dir.rglob("*"):
|
||
if item.is_file() and not item.name.startswith("_"):
|
||
rel_path = item.relative_to(scripts_dir)
|
||
scripts.append(str(rel_path))
|
||
except Exception as e:
|
||
logger.warning(f"Failed to list scripts in {skill_dir}: {e}")
|
||
|
||
return sorted(scripts)
|
||
|
||
|
||
def _get_skill_references(skill_dir: Path) -> List[str]:
|
||
"""获取技能参考资料列表
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
|
||
Returns:
|
||
参考资料相对路径列表 (相对于references目录)
|
||
"""
|
||
refs: List[str] = []
|
||
refs_dir = skill_dir / "references"
|
||
|
||
if not refs_dir.exists():
|
||
return refs
|
||
|
||
try:
|
||
for item in refs_dir.rglob("*"):
|
||
if item.is_file():
|
||
rel_path = item.relative_to(refs_dir)
|
||
refs.append(str(rel_path))
|
||
except Exception as e:
|
||
logger.warning(f"Failed to list references in {skill_dir}: {e}")
|
||
|
||
return sorted(refs)
|
||
|
||
|
||
def validate_skill(skill_dir: Path) -> Dict[str, Any]:
|
||
"""验证技能格式
|
||
|
||
检查技能目录结构是否符合规范。
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
|
||
Returns:
|
||
验证结果字典,包含:
|
||
- valid: 是否有效
|
||
- errors: 错误列表
|
||
- warnings: 警告列表
|
||
"""
|
||
errors: List[str] = []
|
||
warnings: List[str] = []
|
||
|
||
# 检查目录存在
|
||
if not skill_dir.exists():
|
||
errors.append(f"Skill directory does not exist: {skill_dir}")
|
||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||
|
||
if not skill_dir.is_dir():
|
||
errors.append(f"Path is not a directory: {skill_dir}")
|
||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||
|
||
# 检查SKILL.md
|
||
skill_md = skill_dir / "SKILL.md"
|
||
if not skill_md.exists():
|
||
errors.append("SKILL.md is required but not found")
|
||
return {"valid": False, "errors": errors, "warnings": warnings}
|
||
|
||
# 解析frontmatter
|
||
try:
|
||
content = skill_md.read_text(encoding="utf-8").strip()
|
||
if not content.startswith("---"):
|
||
warnings.append("SKILL.md should have YAML frontmatter (starts with ---)")
|
||
else:
|
||
parts = content.split("---", 2)
|
||
if len(parts) < 3:
|
||
errors.append("Invalid YAML frontmatter format")
|
||
else:
|
||
try:
|
||
frontmatter = yaml.safe_load(parts[1].strip()) or {}
|
||
if not isinstance(frontmatter, dict):
|
||
errors.append("YAML frontmatter must be a dictionary")
|
||
else:
|
||
# 检查必需字段
|
||
if "name" not in frontmatter:
|
||
warnings.append("Frontmatter should have 'name' field")
|
||
if "description" not in frontmatter:
|
||
warnings.append("Frontmatter should have 'description' field")
|
||
|
||
# 检查version字段
|
||
version = frontmatter.get("version")
|
||
if version and not isinstance(version, str):
|
||
warnings.append("'version' should be a string")
|
||
|
||
# 检查tools字段
|
||
tools = frontmatter.get("tools")
|
||
if tools and not isinstance(tools, (str, list)):
|
||
warnings.append("'tools' should be a string or list")
|
||
|
||
except yaml.YAMLError as e:
|
||
errors.append(f"Invalid YAML in frontmatter: {e}")
|
||
except Exception as e:
|
||
errors.append(f"Failed to read SKILL.md: {e}")
|
||
|
||
# 检查body内容
|
||
try:
|
||
content = skill_md.read_text(encoding="utf-8")
|
||
body = content
|
||
if content.startswith("---"):
|
||
parts = content.split("---", 2)
|
||
if len(parts) >= 3:
|
||
body = parts[2].strip()
|
||
|
||
if not body:
|
||
warnings.append("SKILL.md body is empty")
|
||
elif len(body) < 50:
|
||
warnings.append("SKILL.md body is very short, consider adding more details")
|
||
except Exception as e:
|
||
errors.append(f"Failed to validate body: {e}")
|
||
|
||
# 检查scripts目录
|
||
scripts_dir = skill_dir / "scripts"
|
||
if scripts_dir.exists():
|
||
if not scripts_dir.is_dir():
|
||
errors.append("'scripts' exists but is not a directory")
|
||
else:
|
||
# 检查是否有可执行脚本
|
||
has_scripts = any(
|
||
f.is_file() and not f.name.startswith("_")
|
||
for f in scripts_dir.iterdir()
|
||
)
|
||
if not has_scripts:
|
||
warnings.append("scripts directory exists but contains no valid scripts")
|
||
|
||
# 检查references目录
|
||
refs_dir = skill_dir / "references"
|
||
if refs_dir.exists() and not refs_dir.is_dir():
|
||
errors.append("'references' exists but is not a directory")
|
||
|
||
return {
|
||
"valid": len(errors) == 0,
|
||
"errors": errors,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def load_skills_from_directory(
|
||
directory: Path,
|
||
source: str = "unknown",
|
||
recursive: bool = False,
|
||
) -> List[Dict[str, Any]]:
|
||
"""从目录加载所有技能
|
||
|
||
Args:
|
||
directory: 包含技能目录的父目录
|
||
source: 技能来源标识
|
||
recursive: 是否递归搜索子目录
|
||
|
||
Returns:
|
||
技能信息列表
|
||
"""
|
||
skills: List[Dict[str, Any]] = []
|
||
|
||
if not directory.exists() or not directory.is_dir():
|
||
logger.warning(f"Directory does not exist: {directory}")
|
||
return skills
|
||
|
||
try:
|
||
for item in directory.iterdir():
|
||
if not item.is_dir():
|
||
continue
|
||
|
||
# 检查是否是技能目录 (包含SKILL.md)
|
||
if (item / "SKILL.md").exists():
|
||
skill_info = load_skill_from_dir(item, source=source)
|
||
if skill_info:
|
||
skills.append(skill_info)
|
||
elif recursive:
|
||
# 递归搜索子目录
|
||
sub_skills = load_skills_from_directory(item, source, recursive)
|
||
skills.extend(sub_skills)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to load skills from {directory}: {e}")
|
||
|
||
return skills
|
||
|
||
|
||
def get_skill_manifest(skill_dir: Path) -> Dict[str, Any]:
|
||
"""获取技能清单
|
||
|
||
生成技能的详细清单,用于调试和展示。
|
||
|
||
Args:
|
||
skill_dir: 技能目录路径
|
||
|
||
Returns:
|
||
技能清单字典
|
||
"""
|
||
info = load_skill_from_dir(skill_dir)
|
||
if not info:
|
||
return {"error": "Failed to load skill"}
|
||
|
||
validation = validate_skill(skill_dir)
|
||
|
||
return {
|
||
"name": info["name"],
|
||
"skill_name": info["skill_name"],
|
||
"version": info["version"],
|
||
"description": info["description"],
|
||
"source": info["source"],
|
||
"path": info["path"],
|
||
"tools": info["tools"],
|
||
"scripts": info["scripts"],
|
||
"references": info["references"],
|
||
"validation": validation,
|
||
"content_preview": info["content"][:500] + "..." if len(info["content"]) > 500 else info["content"],
|
||
}
|