agno 2.3.20__py3-none-any.whl → 2.3.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno/agent/agent.py +26 -1
- agno/agent/remote.py +233 -72
- agno/client/a2a/__init__.py +10 -0
- agno/client/a2a/client.py +554 -0
- agno/client/a2a/schemas.py +112 -0
- agno/client/a2a/utils.py +369 -0
- agno/db/migrations/utils.py +19 -0
- agno/db/migrations/v1_to_v2.py +54 -16
- agno/db/migrations/versions/v2_3_0.py +92 -53
- agno/db/postgres/async_postgres.py +162 -40
- agno/db/postgres/postgres.py +181 -31
- agno/db/postgres/utils.py +6 -2
- agno/eval/agent_as_judge.py +24 -14
- agno/knowledge/chunking/document.py +3 -2
- agno/knowledge/chunking/markdown.py +8 -3
- agno/knowledge/chunking/recursive.py +2 -2
- agno/knowledge/embedder/mistral.py +1 -1
- agno/models/openai/chat.py +1 -1
- agno/models/openai/responses.py +14 -7
- agno/os/middleware/jwt.py +66 -27
- agno/os/routers/agents/router.py +2 -2
- agno/os/routers/evals/evals.py +0 -9
- agno/os/routers/evals/utils.py +6 -6
- agno/os/routers/knowledge/knowledge.py +3 -3
- agno/os/routers/teams/router.py +2 -2
- agno/os/routers/workflows/router.py +2 -2
- agno/reasoning/deepseek.py +11 -1
- agno/reasoning/gemini.py +6 -2
- agno/reasoning/groq.py +8 -3
- agno/reasoning/openai.py +2 -0
- agno/remote/base.py +105 -8
- agno/run/agent.py +19 -19
- agno/run/team.py +19 -19
- agno/skills/__init__.py +17 -0
- agno/skills/agent_skills.py +370 -0
- agno/skills/errors.py +32 -0
- agno/skills/loaders/__init__.py +4 -0
- agno/skills/loaders/base.py +27 -0
- agno/skills/loaders/local.py +216 -0
- agno/skills/skill.py +65 -0
- agno/skills/utils.py +107 -0
- agno/skills/validator.py +277 -0
- agno/team/remote.py +219 -59
- agno/team/team.py +22 -2
- agno/tools/mcp/mcp.py +299 -17
- agno/tools/mcp/multi_mcp.py +269 -14
- agno/utils/mcp.py +49 -8
- agno/utils/string.py +43 -1
- agno/workflow/condition.py +4 -2
- agno/workflow/loop.py +20 -1
- agno/workflow/remote.py +172 -32
- agno/workflow/router.py +4 -1
- agno/workflow/steps.py +4 -0
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/METADATA +59 -130
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/RECORD +58 -44
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from agno.skills.errors import SkillValidationError
|
|
6
|
+
from agno.skills.loaders.base import SkillLoader
|
|
7
|
+
from agno.skills.skill import Skill
|
|
8
|
+
from agno.skills.validator import validate_skill_directory
|
|
9
|
+
from agno.utils.log import log_debug, log_warning
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalSkills(SkillLoader):
|
|
13
|
+
"""Loads skills from the local filesystem.
|
|
14
|
+
|
|
15
|
+
This loader can handle both:
|
|
16
|
+
1. A single skill folder (contains SKILL.md)
|
|
17
|
+
2. A directory containing multiple skill folders
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path: Path to a skill folder or directory containing skill folders.
|
|
21
|
+
validate: Whether to validate skills against the Agent Skills spec.
|
|
22
|
+
If True (default), invalid skills will raise SkillValidationError.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: str, validate: bool = True):
|
|
26
|
+
self.path = Path(path).resolve()
|
|
27
|
+
self.validate = validate
|
|
28
|
+
|
|
29
|
+
def load(self) -> List[Skill]:
|
|
30
|
+
"""Load skills from the local filesystem.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A list of Skill objects loaded from the filesystem.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
FileNotFoundError: If the path doesn't exist.
|
|
37
|
+
"""
|
|
38
|
+
if not self.path.exists():
|
|
39
|
+
raise FileNotFoundError(f"Skills path does not exist: {self.path}")
|
|
40
|
+
|
|
41
|
+
skills: List[Skill] = []
|
|
42
|
+
|
|
43
|
+
# Check if this is a single skill folder or a directory of skills
|
|
44
|
+
skill_md_path = self.path / "SKILL.md"
|
|
45
|
+
if skill_md_path.exists():
|
|
46
|
+
# Single skill folder
|
|
47
|
+
skill = self._load_skill_from_folder(self.path)
|
|
48
|
+
if skill:
|
|
49
|
+
skills.append(skill)
|
|
50
|
+
else:
|
|
51
|
+
# Directory of skill folders
|
|
52
|
+
for item in self.path.iterdir():
|
|
53
|
+
if item.is_dir() and not item.name.startswith("."):
|
|
54
|
+
skill_md = item / "SKILL.md"
|
|
55
|
+
if skill_md.exists():
|
|
56
|
+
skill = self._load_skill_from_folder(item)
|
|
57
|
+
if skill:
|
|
58
|
+
skills.append(skill)
|
|
59
|
+
else:
|
|
60
|
+
log_debug(f"Skipping directory without SKILL.md: {item}")
|
|
61
|
+
|
|
62
|
+
log_debug(f"Loaded {len(skills)} skills from {self.path}")
|
|
63
|
+
return skills
|
|
64
|
+
|
|
65
|
+
def _load_skill_from_folder(self, folder: Path) -> Optional[Skill]:
|
|
66
|
+
"""Load a single skill from a folder.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
folder: Path to the skill folder.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A Skill object if successful, None if there's an error.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
SkillValidationError: If validation is enabled and the skill is invalid.
|
|
76
|
+
"""
|
|
77
|
+
# Validate skill directory structure and content if validation is enabled
|
|
78
|
+
if self.validate:
|
|
79
|
+
errors = validate_skill_directory(folder)
|
|
80
|
+
if errors:
|
|
81
|
+
raise SkillValidationError(
|
|
82
|
+
f"Skill validation failed for '{folder.name}'",
|
|
83
|
+
errors=errors,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
skill_md_path = folder / "SKILL.md"
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
content = skill_md_path.read_text(encoding="utf-8")
|
|
90
|
+
frontmatter, instructions = self._parse_skill_md(content)
|
|
91
|
+
|
|
92
|
+
# Get skill name from the frontmatter or folder name
|
|
93
|
+
name = frontmatter.get("name", folder.name)
|
|
94
|
+
description = frontmatter.get("description", "")
|
|
95
|
+
|
|
96
|
+
# Get optional fields
|
|
97
|
+
license_info = frontmatter.get("license")
|
|
98
|
+
metadata = frontmatter.get("metadata")
|
|
99
|
+
compatibility = frontmatter.get("compatibility")
|
|
100
|
+
allowed_tools = frontmatter.get("allowed-tools")
|
|
101
|
+
|
|
102
|
+
# Discover scripts
|
|
103
|
+
scripts = self._discover_scripts(folder)
|
|
104
|
+
|
|
105
|
+
# Discover references
|
|
106
|
+
references = self._discover_references(folder)
|
|
107
|
+
|
|
108
|
+
return Skill(
|
|
109
|
+
name=name,
|
|
110
|
+
description=description,
|
|
111
|
+
instructions=instructions,
|
|
112
|
+
source_path=str(folder),
|
|
113
|
+
scripts=scripts,
|
|
114
|
+
references=references,
|
|
115
|
+
metadata=metadata,
|
|
116
|
+
license=license_info,
|
|
117
|
+
compatibility=compatibility,
|
|
118
|
+
allowed_tools=allowed_tools,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
except SkillValidationError:
|
|
122
|
+
raise # Re-raise validation errors
|
|
123
|
+
except Exception as e:
|
|
124
|
+
log_warning(f"Error loading skill from {folder}: {e}")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _parse_skill_md(self, content: str) -> Tuple[Dict[str, Any], str]:
|
|
128
|
+
"""Parse SKILL.md content into frontmatter and instructions.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
content: The raw SKILL.md content.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
A tuple of (frontmatter_dict, instructions_body).
|
|
135
|
+
"""
|
|
136
|
+
frontmatter: Dict[str, Any] = {}
|
|
137
|
+
instructions = content
|
|
138
|
+
|
|
139
|
+
# Check for YAML frontmatter (between --- delimiters)
|
|
140
|
+
frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
|
|
141
|
+
|
|
142
|
+
if frontmatter_match:
|
|
143
|
+
frontmatter_text = frontmatter_match.group(1)
|
|
144
|
+
instructions = frontmatter_match.group(2).strip()
|
|
145
|
+
|
|
146
|
+
# Parse YAML frontmatter
|
|
147
|
+
try:
|
|
148
|
+
import yaml
|
|
149
|
+
|
|
150
|
+
frontmatter = yaml.safe_load(frontmatter_text) or {}
|
|
151
|
+
except ImportError:
|
|
152
|
+
# Fallback: simple key-value parsing if yaml not available
|
|
153
|
+
frontmatter = self._parse_simple_frontmatter(frontmatter_text)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
log_warning(f"Error parsing YAML frontmatter: {e}")
|
|
156
|
+
frontmatter = self._parse_simple_frontmatter(frontmatter_text)
|
|
157
|
+
|
|
158
|
+
return frontmatter, instructions
|
|
159
|
+
|
|
160
|
+
def _parse_simple_frontmatter(self, text: str) -> Dict[str, Any]:
|
|
161
|
+
"""Simple fallback frontmatter parser for basic key: value pairs.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
text: The frontmatter text.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
A dictionary of parsed key-value pairs.
|
|
168
|
+
"""
|
|
169
|
+
result: Dict[str, Any] = {}
|
|
170
|
+
for line in text.strip().split("\n"):
|
|
171
|
+
if ":" in line:
|
|
172
|
+
key, value = line.split(":", 1)
|
|
173
|
+
key = key.strip()
|
|
174
|
+
value = value.strip().strip('"').strip("'")
|
|
175
|
+
result[key] = value
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def _discover_scripts(self, folder: Path) -> List[str]:
|
|
179
|
+
"""Discover script files in the scripts/ subdirectory.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
folder: Path to the skill folder.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A list of script filenames.
|
|
186
|
+
"""
|
|
187
|
+
scripts_dir = folder / "scripts"
|
|
188
|
+
if not scripts_dir.exists() or not scripts_dir.is_dir():
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
scripts: List[str] = []
|
|
192
|
+
for item in scripts_dir.iterdir():
|
|
193
|
+
if item.is_file() and not item.name.startswith("."):
|
|
194
|
+
scripts.append(item.name)
|
|
195
|
+
|
|
196
|
+
return sorted(scripts)
|
|
197
|
+
|
|
198
|
+
def _discover_references(self, folder: Path) -> List[str]:
|
|
199
|
+
"""Discover reference files in the references/ subdirectory.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
folder: Path to the skill folder.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
A list of reference filenames.
|
|
206
|
+
"""
|
|
207
|
+
refs_dir = folder / "references"
|
|
208
|
+
if not refs_dir.exists() or not refs_dir.is_dir():
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
references: List[str] = []
|
|
212
|
+
for item in refs_dir.iterdir():
|
|
213
|
+
if item.is_file() and not item.name.startswith("."):
|
|
214
|
+
references.append(item.name)
|
|
215
|
+
|
|
216
|
+
return sorted(references)
|
agno/skills/skill.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Skill:
|
|
7
|
+
"""Represents a skill that an agent can use.
|
|
8
|
+
|
|
9
|
+
A skill provides structured instructions, reference documentation,
|
|
10
|
+
and optional scripts that an agent can access to perform specific tasks.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
name: Unique skill name (from folder name or SKILL.md frontmatter)
|
|
14
|
+
description: Short description of what the skill does
|
|
15
|
+
instructions: Full SKILL.md body (the instructions/guidance for the agent)
|
|
16
|
+
scripts: List of script filenames in scripts/ subdirectory
|
|
17
|
+
references: List of reference filenames in references/ subdirectory
|
|
18
|
+
source_path: Filesystem path to the skill folder
|
|
19
|
+
metadata: Optional metadata from frontmatter (version, author, tags, etc.)
|
|
20
|
+
license: Optional license information
|
|
21
|
+
compatibility: Optional compatibility requirements
|
|
22
|
+
allowed_tools: Optional list of tools this skill is allowed to use
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
instructions: str
|
|
28
|
+
source_path: str
|
|
29
|
+
scripts: List[str] = field(default_factory=list)
|
|
30
|
+
references: List[str] = field(default_factory=list)
|
|
31
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
32
|
+
license: Optional[str] = None
|
|
33
|
+
compatibility: Optional[str] = None
|
|
34
|
+
allowed_tools: Optional[List[str]] = None
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
37
|
+
"""Convert the Skill to a dictionary representation."""
|
|
38
|
+
return {
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"description": self.description,
|
|
41
|
+
"instructions": self.instructions,
|
|
42
|
+
"source_path": self.source_path,
|
|
43
|
+
"scripts": self.scripts,
|
|
44
|
+
"references": self.references,
|
|
45
|
+
"metadata": self.metadata,
|
|
46
|
+
"license": self.license,
|
|
47
|
+
"compatibility": self.compatibility,
|
|
48
|
+
"allowed_tools": self.allowed_tools,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Skill":
|
|
53
|
+
"""Create a Skill from a dictionary."""
|
|
54
|
+
return cls(
|
|
55
|
+
name=data["name"],
|
|
56
|
+
description=data["description"],
|
|
57
|
+
instructions=data["instructions"],
|
|
58
|
+
source_path=data["source_path"],
|
|
59
|
+
scripts=data.get("scripts", []),
|
|
60
|
+
references=data.get("references", []),
|
|
61
|
+
metadata=data.get("metadata"),
|
|
62
|
+
license=data.get("license"),
|
|
63
|
+
compatibility=data.get("compatibility"),
|
|
64
|
+
allowed_tools=data.get("allowed_tools"),
|
|
65
|
+
)
|
agno/skills/utils.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Utility functions for the skills module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_safe_path(base_dir: Path, requested_path: str) -> bool:
|
|
12
|
+
"""Check if the requested path stays within the base directory.
|
|
13
|
+
|
|
14
|
+
This prevents path traversal attacks where a malicious path like
|
|
15
|
+
'../../../etc/passwd' could be used to access files outside the
|
|
16
|
+
intended directory.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
base_dir: The base directory that the path must stay within.
|
|
20
|
+
requested_path: The user-provided path to validate.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
True if the path is safe (stays within base_dir), False otherwise.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
full_path = (base_dir / requested_path).resolve()
|
|
27
|
+
base_resolved = base_dir.resolve()
|
|
28
|
+
return full_path.is_relative_to(base_resolved)
|
|
29
|
+
except (ValueError, OSError):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_executable(file_path: Path) -> None:
|
|
34
|
+
"""Ensure a file has the executable bit set for the owner.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
file_path: Path to the file to make executable.
|
|
38
|
+
"""
|
|
39
|
+
current_mode = file_path.stat().st_mode
|
|
40
|
+
if not (current_mode & stat.S_IXUSR):
|
|
41
|
+
os.chmod(file_path, current_mode | stat.S_IXUSR)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ScriptResult:
|
|
46
|
+
"""Result of a script execution."""
|
|
47
|
+
|
|
48
|
+
stdout: str
|
|
49
|
+
stderr: str
|
|
50
|
+
returncode: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_script(
|
|
54
|
+
script_path: Path,
|
|
55
|
+
args: Optional[List[str]] = None,
|
|
56
|
+
timeout: int = 30,
|
|
57
|
+
cwd: Optional[Path] = None,
|
|
58
|
+
) -> ScriptResult:
|
|
59
|
+
"""Execute a script and return the result.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
script_path: Path to the script to execute.
|
|
63
|
+
args: Optional list of arguments to pass to the script.
|
|
64
|
+
timeout: Maximum execution time in seconds.
|
|
65
|
+
cwd: Working directory for the script.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
ScriptResult with stdout, stderr, and returncode.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
subprocess.TimeoutExpired: If script exceeds timeout.
|
|
72
|
+
FileNotFoundError: If script or interpreter not found.
|
|
73
|
+
"""
|
|
74
|
+
ensure_executable(script_path)
|
|
75
|
+
cmd = [str(script_path), *(args or [])]
|
|
76
|
+
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
cmd,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
cwd=cwd,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return ScriptResult(
|
|
86
|
+
stdout=result.stdout,
|
|
87
|
+
stderr=result.stderr,
|
|
88
|
+
returncode=result.returncode,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_file_safe(file_path: Path, encoding: str = "utf-8") -> str:
|
|
93
|
+
"""Read a file's contents safely.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
file_path: Path to the file to read.
|
|
97
|
+
encoding: File encoding (default: utf-8).
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The file contents as a string.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
FileNotFoundError: If file doesn't exist.
|
|
104
|
+
PermissionError: If file can't be read.
|
|
105
|
+
UnicodeDecodeError: If file can't be decoded.
|
|
106
|
+
"""
|
|
107
|
+
return file_path.read_text(encoding=encoding)
|
agno/skills/validator.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Skill validation logic following the Agent Skills spec."""
|
|
2
|
+
|
|
3
|
+
import unicodedata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
# Constants per Agent Skills Spec
|
|
8
|
+
MAX_SKILL_NAME_LENGTH = 64
|
|
9
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
10
|
+
MAX_COMPATIBILITY_LENGTH = 500
|
|
11
|
+
|
|
12
|
+
# Allowed frontmatter fields per Agent Skills Spec
|
|
13
|
+
ALLOWED_FIELDS = {
|
|
14
|
+
"name",
|
|
15
|
+
"description",
|
|
16
|
+
"license",
|
|
17
|
+
"allowed-tools",
|
|
18
|
+
"metadata",
|
|
19
|
+
"compatibility",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_name(name: str, skill_dir: Optional[Path] = None) -> List[str]:
|
|
24
|
+
"""Validate skill name format and directory match.
|
|
25
|
+
|
|
26
|
+
Skill names support alphanumeric characters plus hyphens.
|
|
27
|
+
Names must be lowercase and cannot start/end with hyphens.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name: The skill name to validate.
|
|
31
|
+
skill_dir: Optional path to skill directory (for name-directory match check).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of validation error messages. Empty list means valid.
|
|
35
|
+
"""
|
|
36
|
+
errors = []
|
|
37
|
+
|
|
38
|
+
if not name or not isinstance(name, str) or not name.strip():
|
|
39
|
+
errors.append("Field 'name' must be a non-empty string")
|
|
40
|
+
return errors
|
|
41
|
+
|
|
42
|
+
name = unicodedata.normalize("NFKC", name.strip())
|
|
43
|
+
|
|
44
|
+
if len(name) > MAX_SKILL_NAME_LENGTH:
|
|
45
|
+
errors.append(f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit ({len(name)} chars)")
|
|
46
|
+
|
|
47
|
+
if name != name.lower():
|
|
48
|
+
errors.append(f"Skill name '{name}' must be lowercase")
|
|
49
|
+
|
|
50
|
+
if name.startswith("-") or name.endswith("-"):
|
|
51
|
+
errors.append("Skill name cannot start or end with a hyphen")
|
|
52
|
+
|
|
53
|
+
if "--" in name:
|
|
54
|
+
errors.append("Skill name cannot contain consecutive hyphens")
|
|
55
|
+
|
|
56
|
+
if not all(c.isalnum() or c == "-" for c in name):
|
|
57
|
+
errors.append(
|
|
58
|
+
f"Skill name '{name}' contains invalid characters. Only letters, digits, and hyphens are allowed."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if skill_dir:
|
|
62
|
+
dir_name = unicodedata.normalize("NFKC", skill_dir.name)
|
|
63
|
+
if dir_name != name:
|
|
64
|
+
errors.append(f"Directory name '{dir_name}' must match skill name '{name}'")
|
|
65
|
+
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_description(description: str) -> List[str]:
|
|
70
|
+
"""Validate description format.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
description: The skill description to validate.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of validation error messages. Empty list means valid.
|
|
77
|
+
"""
|
|
78
|
+
errors = []
|
|
79
|
+
|
|
80
|
+
if not description or not isinstance(description, str) or not description.strip():
|
|
81
|
+
errors.append("Field 'description' must be a non-empty string")
|
|
82
|
+
return errors
|
|
83
|
+
|
|
84
|
+
if len(description) > MAX_DESCRIPTION_LENGTH:
|
|
85
|
+
errors.append(f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit ({len(description)} chars)")
|
|
86
|
+
|
|
87
|
+
return errors
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_compatibility(compatibility: str) -> List[str]:
|
|
91
|
+
"""Validate compatibility format.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
compatibility: The compatibility string to validate.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of validation error messages. Empty list means valid.
|
|
98
|
+
"""
|
|
99
|
+
errors = []
|
|
100
|
+
|
|
101
|
+
if not isinstance(compatibility, str):
|
|
102
|
+
errors.append("Field 'compatibility' must be a string")
|
|
103
|
+
return errors
|
|
104
|
+
|
|
105
|
+
if len(compatibility) > MAX_COMPATIBILITY_LENGTH:
|
|
106
|
+
errors.append(f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit ({len(compatibility)} chars)")
|
|
107
|
+
|
|
108
|
+
return errors
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_license(license_val: str) -> List[str]:
|
|
112
|
+
"""Validate license field.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
license_val: The license string to validate.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of validation error messages. Empty list means valid.
|
|
119
|
+
"""
|
|
120
|
+
errors: List[str] = []
|
|
121
|
+
|
|
122
|
+
if not isinstance(license_val, str):
|
|
123
|
+
errors.append("Field 'license' must be a string")
|
|
124
|
+
|
|
125
|
+
return errors
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _validate_allowed_tools(allowed_tools) -> List[str]:
|
|
129
|
+
"""Validate allowed-tools field.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
allowed_tools: The allowed-tools value to validate.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of validation error messages. Empty list means valid.
|
|
136
|
+
"""
|
|
137
|
+
errors = []
|
|
138
|
+
|
|
139
|
+
if not isinstance(allowed_tools, list):
|
|
140
|
+
errors.append("Field 'allowed-tools' must be a list")
|
|
141
|
+
return errors
|
|
142
|
+
|
|
143
|
+
if not all(isinstance(tool, str) for tool in allowed_tools):
|
|
144
|
+
errors.append("Field 'allowed-tools' must be a list of strings")
|
|
145
|
+
|
|
146
|
+
return errors
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _validate_metadata_value(metadata_val) -> List[str]:
|
|
150
|
+
"""Validate metadata field value.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
metadata_val: The metadata value to validate.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of validation error messages. Empty list means valid.
|
|
157
|
+
"""
|
|
158
|
+
errors = []
|
|
159
|
+
|
|
160
|
+
if not isinstance(metadata_val, dict):
|
|
161
|
+
errors.append("Field 'metadata' must be a dictionary")
|
|
162
|
+
|
|
163
|
+
return errors
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate_metadata_fields(metadata: Dict) -> List[str]:
|
|
167
|
+
"""Validate that only allowed fields are present in frontmatter.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
metadata: Parsed frontmatter dictionary.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of validation error messages. Empty list means valid.
|
|
174
|
+
"""
|
|
175
|
+
errors = []
|
|
176
|
+
|
|
177
|
+
extra_fields = set(metadata.keys()) - ALLOWED_FIELDS
|
|
178
|
+
if extra_fields:
|
|
179
|
+
errors.append(
|
|
180
|
+
f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}. "
|
|
181
|
+
f"Only {sorted(ALLOWED_FIELDS)} are allowed."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return errors
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_metadata(metadata: Dict, skill_dir: Optional[Path] = None) -> List[str]:
|
|
188
|
+
"""Validate parsed skill metadata.
|
|
189
|
+
|
|
190
|
+
This is the core validation function that works on already-parsed metadata.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
metadata: Parsed YAML frontmatter dictionary.
|
|
194
|
+
skill_dir: Optional path to skill directory (for name-directory match check).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of validation error messages. Empty list means valid.
|
|
198
|
+
"""
|
|
199
|
+
errors = []
|
|
200
|
+
errors.extend(_validate_metadata_fields(metadata))
|
|
201
|
+
|
|
202
|
+
if "name" not in metadata:
|
|
203
|
+
errors.append("Missing required field in frontmatter: name")
|
|
204
|
+
else:
|
|
205
|
+
errors.extend(_validate_name(metadata["name"], skill_dir))
|
|
206
|
+
|
|
207
|
+
if "description" not in metadata:
|
|
208
|
+
errors.append("Missing required field in frontmatter: description")
|
|
209
|
+
else:
|
|
210
|
+
errors.extend(_validate_description(metadata["description"]))
|
|
211
|
+
|
|
212
|
+
if "compatibility" in metadata:
|
|
213
|
+
errors.extend(_validate_compatibility(metadata["compatibility"]))
|
|
214
|
+
|
|
215
|
+
if "license" in metadata:
|
|
216
|
+
errors.extend(_validate_license(metadata["license"]))
|
|
217
|
+
|
|
218
|
+
if "allowed-tools" in metadata:
|
|
219
|
+
errors.extend(_validate_allowed_tools(metadata["allowed-tools"]))
|
|
220
|
+
|
|
221
|
+
if "metadata" in metadata:
|
|
222
|
+
errors.extend(_validate_metadata_value(metadata["metadata"]))
|
|
223
|
+
|
|
224
|
+
return errors
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate_skill_directory(skill_dir: Path) -> List[str]:
|
|
228
|
+
"""Validate a skill directory structure and contents.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
skill_dir: Path to the skill directory.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
List of validation error messages. Empty list means valid.
|
|
235
|
+
"""
|
|
236
|
+
import yaml
|
|
237
|
+
|
|
238
|
+
from agno.skills.errors import SkillParseError
|
|
239
|
+
|
|
240
|
+
skill_dir = Path(skill_dir)
|
|
241
|
+
|
|
242
|
+
if not skill_dir.exists():
|
|
243
|
+
return [f"Path does not exist: {skill_dir}"]
|
|
244
|
+
|
|
245
|
+
if not skill_dir.is_dir():
|
|
246
|
+
return [f"Not a directory: {skill_dir}"]
|
|
247
|
+
|
|
248
|
+
# Find SKILL.md file (only uppercase supported)
|
|
249
|
+
skill_md = skill_dir / "SKILL.md"
|
|
250
|
+
if not skill_md.exists():
|
|
251
|
+
return ["Missing required file: SKILL.md"]
|
|
252
|
+
|
|
253
|
+
# Parse frontmatter
|
|
254
|
+
try:
|
|
255
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
if not content.startswith("---"):
|
|
258
|
+
raise SkillParseError("SKILL.md must start with YAML frontmatter (---)")
|
|
259
|
+
|
|
260
|
+
parts = content.split("---", 2)
|
|
261
|
+
if len(parts) < 3:
|
|
262
|
+
raise SkillParseError("SKILL.md frontmatter not properly closed with ---")
|
|
263
|
+
|
|
264
|
+
frontmatter_str = parts[1]
|
|
265
|
+
metadata = yaml.safe_load(frontmatter_str)
|
|
266
|
+
|
|
267
|
+
if not isinstance(metadata, dict):
|
|
268
|
+
raise SkillParseError("SKILL.md frontmatter must be a YAML mapping")
|
|
269
|
+
|
|
270
|
+
except SkillParseError as e:
|
|
271
|
+
return [str(e)]
|
|
272
|
+
except yaml.YAMLError as e:
|
|
273
|
+
return [f"Invalid YAML in frontmatter: {e}"]
|
|
274
|
+
except Exception as e:
|
|
275
|
+
return [f"Error reading SKILL.md: {e}"]
|
|
276
|
+
|
|
277
|
+
return validate_metadata(metadata, skill_dir)
|