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.
Files changed (58) hide show
  1. agno/agent/agent.py +26 -1
  2. agno/agent/remote.py +233 -72
  3. agno/client/a2a/__init__.py +10 -0
  4. agno/client/a2a/client.py +554 -0
  5. agno/client/a2a/schemas.py +112 -0
  6. agno/client/a2a/utils.py +369 -0
  7. agno/db/migrations/utils.py +19 -0
  8. agno/db/migrations/v1_to_v2.py +54 -16
  9. agno/db/migrations/versions/v2_3_0.py +92 -53
  10. agno/db/postgres/async_postgres.py +162 -40
  11. agno/db/postgres/postgres.py +181 -31
  12. agno/db/postgres/utils.py +6 -2
  13. agno/eval/agent_as_judge.py +24 -14
  14. agno/knowledge/chunking/document.py +3 -2
  15. agno/knowledge/chunking/markdown.py +8 -3
  16. agno/knowledge/chunking/recursive.py +2 -2
  17. agno/knowledge/embedder/mistral.py +1 -1
  18. agno/models/openai/chat.py +1 -1
  19. agno/models/openai/responses.py +14 -7
  20. agno/os/middleware/jwt.py +66 -27
  21. agno/os/routers/agents/router.py +2 -2
  22. agno/os/routers/evals/evals.py +0 -9
  23. agno/os/routers/evals/utils.py +6 -6
  24. agno/os/routers/knowledge/knowledge.py +3 -3
  25. agno/os/routers/teams/router.py +2 -2
  26. agno/os/routers/workflows/router.py +2 -2
  27. agno/reasoning/deepseek.py +11 -1
  28. agno/reasoning/gemini.py +6 -2
  29. agno/reasoning/groq.py +8 -3
  30. agno/reasoning/openai.py +2 -0
  31. agno/remote/base.py +105 -8
  32. agno/run/agent.py +19 -19
  33. agno/run/team.py +19 -19
  34. agno/skills/__init__.py +17 -0
  35. agno/skills/agent_skills.py +370 -0
  36. agno/skills/errors.py +32 -0
  37. agno/skills/loaders/__init__.py +4 -0
  38. agno/skills/loaders/base.py +27 -0
  39. agno/skills/loaders/local.py +216 -0
  40. agno/skills/skill.py +65 -0
  41. agno/skills/utils.py +107 -0
  42. agno/skills/validator.py +277 -0
  43. agno/team/remote.py +219 -59
  44. agno/team/team.py +22 -2
  45. agno/tools/mcp/mcp.py +299 -17
  46. agno/tools/mcp/multi_mcp.py +269 -14
  47. agno/utils/mcp.py +49 -8
  48. agno/utils/string.py +43 -1
  49. agno/workflow/condition.py +4 -2
  50. agno/workflow/loop.py +20 -1
  51. agno/workflow/remote.py +172 -32
  52. agno/workflow/router.py +4 -1
  53. agno/workflow/steps.py +4 -0
  54. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/METADATA +59 -130
  55. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/RECORD +58 -44
  56. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
  57. {agno-2.3.20.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
  58. {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)
@@ -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)