- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
427 lines
16 KiB
Python
427 lines
16 KiB
Python
"""Skill data model and loading utilities for AgentSkills.io skills.
|
|
|
|
This module defines the Skill dataclass and provides classmethods for
|
|
discovering, parsing, and loading skills from the filesystem, raw content,
|
|
or HTTPS URLs. Skills are directories containing a SKILL.md file with YAML
|
|
frontmatter metadata and markdown instructions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
_MAX_SKILL_NAME_LENGTH = 64
|
|
|
|
|
|
def _find_skill_md(skill_dir: Path) -> Path:
|
|
"""Find the SKILL.md file in a skill directory.
|
|
|
|
Searches for SKILL.md (case-sensitive preferred) or skill.md as a fallback.
|
|
|
|
Args:
|
|
skill_dir: Path to the skill directory.
|
|
|
|
Returns:
|
|
Path to the SKILL.md file.
|
|
|
|
Raises:
|
|
FileNotFoundError: If no SKILL.md file is found in the directory.
|
|
"""
|
|
for name in ("SKILL.md", "skill.md"):
|
|
candidate = skill_dir / name
|
|
if candidate.is_file():
|
|
return candidate
|
|
|
|
raise FileNotFoundError(f"path=<{skill_dir}> | no SKILL.md found in skill directory")
|
|
|
|
|
|
def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
|
|
"""Parse YAML frontmatter and body from SKILL.md content.
|
|
|
|
Extracts the YAML frontmatter between ``---`` delimiters at line boundaries
|
|
and returns parsed key-value pairs along with the remaining markdown body.
|
|
|
|
Args:
|
|
content: Full content of a SKILL.md file.
|
|
|
|
Returns:
|
|
Tuple of (frontmatter_dict, body_string).
|
|
|
|
Raises:
|
|
ValueError: If the frontmatter is malformed or missing required delimiters.
|
|
"""
|
|
stripped = content.strip()
|
|
if not stripped.startswith("---"):
|
|
raise ValueError("SKILL.md must start with --- frontmatter delimiter")
|
|
|
|
# Find the closing --- delimiter (first line after the opener that is only dashes)
|
|
match = re.search(r"\n^---\s*$", stripped, re.MULTILINE)
|
|
if match is None:
|
|
raise ValueError("SKILL.md frontmatter missing closing --- delimiter")
|
|
|
|
frontmatter_str = stripped[3 : match.start()].strip()
|
|
body = stripped[match.end() :].strip()
|
|
|
|
try:
|
|
result = yaml.safe_load(frontmatter_str)
|
|
except yaml.YAMLError:
|
|
# AgentSkills spec recommends handling malformed YAML (e.g. unquoted colons in values)
|
|
# to improve cross-client compatibility. See: agentskills.io/client-implementation/adding-skills-support
|
|
logger.warning("YAML parse failed, retrying with colon-quoting fallback")
|
|
fixed = _fix_yaml_colons(frontmatter_str)
|
|
try:
|
|
result = yaml.safe_load(fixed)
|
|
except yaml.YAMLError as error:
|
|
raise ValueError(f"malformed YAML frontmatter: {error}") from error
|
|
|
|
frontmatter: dict[str, Any] = result if isinstance(result, dict) else {}
|
|
return frontmatter, body
|
|
|
|
|
|
def _fix_yaml_colons(yaml_str: str) -> str:
|
|
"""Attempt to fix common YAML issues like unquoted colons in values.
|
|
|
|
Wraps values containing colons in double quotes to handle cases like:
|
|
``description: Use this skill when: the user asks about PDFs``
|
|
|
|
Args:
|
|
yaml_str: The raw YAML string to fix.
|
|
|
|
Returns:
|
|
The fixed YAML string.
|
|
"""
|
|
lines: list[str] = []
|
|
for line in yaml_str.splitlines():
|
|
# Match key: value where value contains another colon
|
|
match = re.match(r"^(\s*\w[\w-]*):\s+(.+)$", line)
|
|
if match:
|
|
key, value = match.group(1), match.group(2)
|
|
# If value contains a colon and isn't already quoted
|
|
if ":" in value and not (value.startswith('"') or value.startswith("'")):
|
|
line = f'{key}: "{value}"'
|
|
lines.append(line)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _validate_skill_name(name: str, dir_path: Path | None = None, *, strict: bool = False) -> None:
|
|
"""Validate a skill name per the AgentSkills.io specification.
|
|
|
|
In lenient mode (default), logs warnings for cosmetic issues but does not raise.
|
|
In strict mode, raises ValueError for any validation failure.
|
|
|
|
Rules checked:
|
|
- 1-64 characters long
|
|
- Lowercase alphanumeric characters and hyphens only
|
|
- Cannot start or end with a hyphen
|
|
- No consecutive hyphens
|
|
- Must match parent directory name (if loaded from disk)
|
|
|
|
Args:
|
|
name: The skill name to validate.
|
|
dir_path: Optional path to the skill directory for name matching.
|
|
strict: If True, raise ValueError on any issue. If False (default), log warnings.
|
|
|
|
Raises:
|
|
ValueError: If the skill name is empty, or if strict=True and any rule is violated.
|
|
"""
|
|
if not name:
|
|
raise ValueError("Skill name cannot be empty")
|
|
|
|
if len(name) > _MAX_SKILL_NAME_LENGTH:
|
|
msg = "name=<%s> | skill name exceeds %d character limit"
|
|
if strict:
|
|
raise ValueError(msg % (name, _MAX_SKILL_NAME_LENGTH))
|
|
logger.warning(msg, name, _MAX_SKILL_NAME_LENGTH)
|
|
|
|
if not _SKILL_NAME_PATTERN.match(name):
|
|
msg = (
|
|
"name=<%s> | skill name should be 1-64 lowercase alphanumeric characters or hyphens, "
|
|
"should not start/end with hyphen"
|
|
)
|
|
if strict:
|
|
raise ValueError(msg % name)
|
|
logger.warning(msg, name)
|
|
|
|
if "--" in name:
|
|
msg = "name=<%s> | skill name contains consecutive hyphens"
|
|
if strict:
|
|
raise ValueError(msg % name)
|
|
logger.warning(msg, name)
|
|
|
|
if dir_path is not None and dir_path.name != name:
|
|
msg = "name=<%s>, directory=<%s> | skill name does not match parent directory name"
|
|
if strict:
|
|
raise ValueError(msg % (name, dir_path.name))
|
|
logger.warning(msg, name, dir_path.name)
|
|
|
|
|
|
def _build_skill_from_frontmatter(
|
|
frontmatter: dict[str, Any],
|
|
body: str,
|
|
) -> Skill:
|
|
"""Build a Skill instance from parsed frontmatter and body.
|
|
|
|
Args:
|
|
frontmatter: Parsed YAML frontmatter dict.
|
|
body: Markdown body content.
|
|
|
|
Returns:
|
|
A populated Skill instance.
|
|
"""
|
|
# Parse allowed-tools (space-delimited string or YAML list)
|
|
allowed_tools_raw = frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
|
|
allowed_tools: list[str] | None = None
|
|
if isinstance(allowed_tools_raw, str) and allowed_tools_raw.strip():
|
|
allowed_tools = allowed_tools_raw.strip().split()
|
|
elif isinstance(allowed_tools_raw, list):
|
|
allowed_tools = [str(item) for item in allowed_tools_raw if item]
|
|
|
|
# Parse metadata (nested mapping)
|
|
metadata_raw = frontmatter.get("metadata", {})
|
|
metadata: dict[str, Any] = {}
|
|
if isinstance(metadata_raw, dict):
|
|
metadata = {str(k): v for k, v in metadata_raw.items()}
|
|
|
|
skill_license = frontmatter.get("license")
|
|
compatibility = frontmatter.get("compatibility")
|
|
|
|
return Skill(
|
|
name=frontmatter["name"],
|
|
description=frontmatter["description"],
|
|
instructions=body,
|
|
allowed_tools=allowed_tools,
|
|
metadata=metadata,
|
|
license=str(skill_license) if skill_license else None,
|
|
compatibility=str(compatibility) if compatibility else None,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Skill:
|
|
r"""Represents an agent skill with metadata and instructions.
|
|
|
|
A skill encapsulates a set of instructions and metadata that can be
|
|
dynamically loaded by an agent at runtime. Skills support progressive
|
|
disclosure: metadata is shown upfront in the system prompt, and full
|
|
instructions are loaded on demand via a tool.
|
|
|
|
Skills can be created directly or via convenience classmethods::
|
|
|
|
# From a skill directory on disk
|
|
skill = Skill.from_file("./skills/my-skill")
|
|
|
|
# From raw SKILL.md content
|
|
skill = Skill.from_content("---\nname: my-skill\n...")
|
|
|
|
# Load all skills from a parent directory
|
|
skills = Skill.from_directory("./skills/")
|
|
|
|
# From an HTTPS URL
|
|
skill = Skill.from_url("https://example.com/SKILL.md")
|
|
|
|
Attributes:
|
|
name: Unique identifier for the skill (1-64 chars, lowercase alphanumeric + hyphens).
|
|
description: Human-readable description of what the skill does.
|
|
instructions: Full markdown instructions from the SKILL.md body.
|
|
path: Filesystem path to the skill directory, if loaded from disk.
|
|
allowed_tools: List of tool names the skill is allowed to use. (Experimental: not yet enforced)
|
|
metadata: Additional key-value metadata from the SKILL.md frontmatter.
|
|
license: License identifier (e.g., "Apache-2.0").
|
|
compatibility: Compatibility information string.
|
|
"""
|
|
|
|
name: str
|
|
description: str
|
|
instructions: str = ""
|
|
path: Path | None = None
|
|
allowed_tools: list[str] | None = None
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
license: str | None = None
|
|
compatibility: str | None = None
|
|
|
|
@classmethod
|
|
def from_file(cls, skill_path: str | Path, *, strict: bool = False) -> Skill:
|
|
"""Load a single skill from a directory containing SKILL.md.
|
|
|
|
Resolves the filesystem path, reads the file content, and delegates
|
|
to ``from_content`` for parsing. After loading, sets the skill's
|
|
``path`` and validates the skill name against the parent directory.
|
|
|
|
Args:
|
|
skill_path: Path to the skill directory or the SKILL.md file itself.
|
|
strict: If True, raise on any validation issue. If False (default), warn and load anyway.
|
|
|
|
Returns:
|
|
A Skill instance populated from the SKILL.md file.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the path does not exist or SKILL.md is not found.
|
|
ValueError: If the skill metadata is invalid.
|
|
"""
|
|
skill_path = Path(skill_path).resolve()
|
|
|
|
if skill_path.is_file() and skill_path.name.lower() == "skill.md":
|
|
skill_md_path = skill_path
|
|
skill_dir = skill_path.parent
|
|
elif skill_path.is_dir():
|
|
skill_dir = skill_path
|
|
skill_md_path = _find_skill_md(skill_dir)
|
|
else:
|
|
raise FileNotFoundError(
|
|
f"path=<{skill_path}> | skill path does not exist or is not a valid skill directory"
|
|
)
|
|
|
|
logger.debug("path=<%s> | loading skill", skill_md_path)
|
|
|
|
content = skill_md_path.read_text(encoding="utf-8")
|
|
skill = cls.from_content(content, strict=strict)
|
|
|
|
# Set path and check directory name match (from_content already validated the name format)
|
|
skill.path = skill_dir
|
|
if skill_dir.name != skill.name:
|
|
msg = "name=<%s>, directory=<%s> | skill name does not match parent directory name"
|
|
if strict:
|
|
raise ValueError(msg % (skill.name, skill_dir.name))
|
|
logger.warning(msg, skill.name, skill_dir.name)
|
|
|
|
logger.debug("name=<%s>, path=<%s> | skill loaded successfully", skill.name, skill.path)
|
|
return skill
|
|
|
|
@classmethod
|
|
def from_content(cls, content: str, *, strict: bool = False) -> Skill:
|
|
"""Parse SKILL.md content into a Skill instance.
|
|
|
|
This is a convenience method for creating a Skill from raw SKILL.md
|
|
content (YAML frontmatter + markdown body) without requiring a file on
|
|
disk.
|
|
|
|
Example::
|
|
|
|
content = '''---
|
|
name: my-skill
|
|
description: Does something useful
|
|
---
|
|
# Instructions
|
|
Follow these steps...
|
|
'''
|
|
skill = Skill.from_content(content)
|
|
|
|
Args:
|
|
content: Raw SKILL.md content with YAML frontmatter and markdown body.
|
|
strict: If True, raise on any validation issue. If False (default), warn and load anyway.
|
|
|
|
Returns:
|
|
A Skill instance populated from the parsed content.
|
|
|
|
Raises:
|
|
ValueError: If the content is missing required fields or has invalid frontmatter.
|
|
"""
|
|
frontmatter, body = _parse_frontmatter(content)
|
|
|
|
name = frontmatter.get("name")
|
|
if not isinstance(name, str) or not name:
|
|
raise ValueError("SKILL.md content must have a 'name' field in frontmatter")
|
|
|
|
description = frontmatter.get("description")
|
|
if not isinstance(description, str) or not description:
|
|
raise ValueError("SKILL.md content must have a 'description' field in frontmatter")
|
|
|
|
_validate_skill_name(name, strict=strict)
|
|
|
|
return _build_skill_from_frontmatter(frontmatter, body)
|
|
|
|
@classmethod
|
|
def from_url(cls, url: str, *, strict: bool = False) -> Skill:
|
|
"""Load a skill by fetching its SKILL.md content from an HTTPS URL.
|
|
|
|
Fetches the raw SKILL.md content over HTTPS and parses it using
|
|
:meth:`from_content`. The URL must point directly to the raw
|
|
file content (not an HTML page).
|
|
|
|
Example::
|
|
|
|
skill = Skill.from_url(
|
|
"https://raw.githubusercontent.com/org/repo/main/SKILL.md"
|
|
)
|
|
|
|
Args:
|
|
url: An ``https://`` URL pointing directly to raw SKILL.md content.
|
|
strict: If True, raise on any validation issue. If False (default),
|
|
warn and load anyway.
|
|
|
|
Returns:
|
|
A Skill instance populated from the fetched SKILL.md content.
|
|
|
|
Raises:
|
|
ValueError: If ``url`` is not an ``https://`` URL.
|
|
RuntimeError: If the SKILL.md content cannot be fetched.
|
|
"""
|
|
if not url.startswith("https://"):
|
|
raise ValueError(f"url=<{url}> | not a valid HTTPS URL")
|
|
|
|
logger.info("url=<%s> | fetching skill content", url)
|
|
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "strands-agents-sdk"}) # noqa: S310
|
|
with urllib.request.urlopen(req, timeout=30) as response: # noqa: S310
|
|
content: str = response.read().decode("utf-8")
|
|
except urllib.error.HTTPError as e:
|
|
raise RuntimeError(f"url=<{url}> | HTTP {e.code}: {e.reason}") from e
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"url=<{url}> | failed to fetch skill: {e.reason}") from e
|
|
|
|
return cls.from_content(content, strict=strict)
|
|
|
|
@classmethod
|
|
def from_directory(cls, skills_dir: str | Path, *, strict: bool = False) -> list[Skill]:
|
|
"""Load all skills from a parent directory containing skill subdirectories.
|
|
|
|
Each subdirectory containing a SKILL.md file is treated as a skill.
|
|
Subdirectories without SKILL.md are silently skipped.
|
|
|
|
Args:
|
|
skills_dir: Path to the parent directory containing skill subdirectories.
|
|
strict: If True, raise on any validation issue. If False (default), warn and load anyway.
|
|
|
|
Returns:
|
|
List of Skill instances loaded from the directory.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the skills directory does not exist.
|
|
"""
|
|
skills_dir = Path(skills_dir).resolve()
|
|
|
|
if not skills_dir.is_dir():
|
|
raise FileNotFoundError(f"path=<{skills_dir}> | skills directory does not exist")
|
|
|
|
skills: list[Skill] = []
|
|
|
|
for child in sorted(skills_dir.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
|
|
try:
|
|
_find_skill_md(child)
|
|
except FileNotFoundError:
|
|
logger.debug("path=<%s> | skipping directory without SKILL.md", child)
|
|
continue
|
|
|
|
try:
|
|
skill = cls.from_file(child, strict=strict)
|
|
skills.append(skill)
|
|
except (ValueError, FileNotFoundError) as e:
|
|
logger.warning("path=<%s> | skipping skill due to error: %s", child, e)
|
|
|
|
logger.debug("path=<%s>, count=<%d> | loaded skills from directory", skills_dir, len(skills))
|
|
return skills
|