agno 2.3.21__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 (52) 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/knowledge/chunking/document.py +3 -2
  14. agno/knowledge/chunking/markdown.py +8 -3
  15. agno/knowledge/chunking/recursive.py +2 -2
  16. agno/models/openai/chat.py +1 -1
  17. agno/models/openai/responses.py +14 -7
  18. agno/os/middleware/jwt.py +66 -27
  19. agno/os/routers/agents/router.py +2 -2
  20. agno/os/routers/knowledge/knowledge.py +3 -3
  21. agno/os/routers/teams/router.py +2 -2
  22. agno/os/routers/workflows/router.py +2 -2
  23. agno/reasoning/deepseek.py +11 -1
  24. agno/reasoning/gemini.py +6 -2
  25. agno/reasoning/groq.py +8 -3
  26. agno/reasoning/openai.py +2 -0
  27. agno/remote/base.py +105 -8
  28. agno/skills/__init__.py +17 -0
  29. agno/skills/agent_skills.py +370 -0
  30. agno/skills/errors.py +32 -0
  31. agno/skills/loaders/__init__.py +4 -0
  32. agno/skills/loaders/base.py +27 -0
  33. agno/skills/loaders/local.py +216 -0
  34. agno/skills/skill.py +65 -0
  35. agno/skills/utils.py +107 -0
  36. agno/skills/validator.py +277 -0
  37. agno/team/remote.py +219 -59
  38. agno/team/team.py +22 -2
  39. agno/tools/mcp/mcp.py +299 -17
  40. agno/tools/mcp/multi_mcp.py +269 -14
  41. agno/utils/mcp.py +49 -8
  42. agno/utils/string.py +43 -1
  43. agno/workflow/condition.py +4 -2
  44. agno/workflow/loop.py +20 -1
  45. agno/workflow/remote.py +172 -32
  46. agno/workflow/router.py +4 -1
  47. agno/workflow/steps.py +4 -0
  48. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/METADATA +13 -14
  49. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/RECORD +52 -38
  50. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/WHEEL +0 -0
  51. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/licenses/LICENSE +0 -0
  52. {agno-2.3.21.dist-info → agno-2.3.22.dist-info}/top_level.txt +0 -0
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)