The Atlas AnyLegal OSS — documentation bound to its code
20 documents

How agent behaviour is assembled: skills & tools

The system prompt is deliberately thin on procedure — it defers to skills and a tool registry. Trace how a SKILL.md becomes a loaded procedure and how the tool pool is built and scoped.

backend/anylegal_oss/workspace/skills/skill_loader.py315 lines · SkillLoader.discover_skills L83–113
Outline 15 symbols
1"""
2SkillLoader - Load and manage SKILL.md files
3
4Provides skill discovery, loading, and eligibility checking.
5Skills are defined in SKILL.md files with YAML frontmatter.
6
7Compatible with OpenSkills/Anthropic models SKILL.md format.
8"""
9
10import os
11import re
12import yaml
13import logging
14from pathlib import Path
15from dataclasses import dataclass, field
16from typing import Dict, List, Any, Optional
17
18logger = logging.getLogger(__name__)
19
20@dataclass
21class SkillRequirements:
22 """Requirements for a skill to be eligible."""
23 tools: List[str] = field(default_factory=list)
24 config: List[str] = field(default_factory=list)
25 binaries: List[str] = field(default_factory=list)
26
27@dataclass
28class SkillMetadata:
29 """Metadata parsed from SKILL.md frontmatter."""
30 name: str
31 emoji: str = ""
32 description: str = ""
33 requires: SkillRequirements = field(default_factory=SkillRequirements)
34 version: str = "1.0.0"
35 author: str = ""
36 tags: List[str] = field(default_factory=list)
37
38@dataclass
39class Skill:
40 """A loaded skill with metadata and content."""
41 metadata: SkillMetadata
42 content: str
43 path: Path
44
45 when_to_use: str = ""
46 process: str = ""
47 output_format: str = ""
48 guidelines: str = ""
49
50class SkillLoader:
51 """
52 Loads and manages SKILL.md files.
53
54 Supports:
55 - Discovering skills from multiple directories
56 - Parsing YAML frontmatter for metadata
57 - Progressive disclosure (L1, L2, L3 context levels)
58 - Eligibility checking based on requirements
59 """
60
61 def __init__(
62 self,
63 skills_dirs: Optional[List[str]] = None,
64 config: Optional[Dict[str, Any]] = None
65 ):
66 """
67 Initialize the skill loader.
68
69 Args:
70 skills_dirs: List of directories to search for skills
71 config: Configuration dict for eligibility checks
72 """
73 self.skills_dirs = skills_dirs or []
74 self.config = config or {}
75 self._cache: Dict[str, Skill] = {}
76 self._metadata_cache: Dict[str, SkillMetadata] = {}
77
78 def add_skills_dir(self, path: str) -> None:
79 """Add a directory to search for skills."""
80 if path not in self.skills_dirs:
81 self.skills_dirs.append(path)
82
83 def discover_skills(self, check_eligibility: bool = False) -> List[SkillMetadata]:
84 """
85 Discover all available skills.
86
87 Args:
88 check_eligibility: If True, only return eligible skills
89
90 Returns:
91 List of SkillMetadata for discovered skills
92 """
93 skills = []
94
95 for skills_dir in self.skills_dirs:
96 dir_path = Path(skills_dir)
97 if not dir_path.exists():
98 logger.debug(f"Skills directory not found: {skills_dir}")
99 continue
100
101 for skill_path in dir_path.glob("*/SKILL.md"):
102 try:
103 metadata = self._parse_metadata(skill_path)
104 if metadata:
105 if check_eligibility and not self.is_eligible(metadata):
106 logger.debug(f"Skill not eligible: {metadata.name}")
107 continue
108 skills.append(metadata)
109 self._metadata_cache[metadata.name] = metadata
110 except Exception as e:
111 logger.error(f"Error parsing skill {skill_path}: {e}")
112
113 return skills
114
115 def load_skill(self, name: str, level: int = 2) -> Optional[Skill]:
116 """
117 Load a skill by name.
118
119 Args:
120 name: Skill name (e.g., 'contract-review')
121 level: Context level (1=minimal, 2=standard, 3=full)
122
123 Returns:
124 Loaded Skill or None if not found
125 """
126 # Defense-in-depth: skill names are admin-controlled today, but reject
127 # any traversal-shaped string so a future caller can't turn this into
128 # an arbitrary-file read.
129 if not name or ".." in name or name.startswith(("/", "\\")) or "/" in name or "\\" in name:
130 logger.warning(f"Rejecting skill name with path components: {name!r}")
131 return None
132
133 cache_key = f"{name}:{level}"
134 if cache_key in self._cache:
135 return self._cache[cache_key]
136
137 for skills_dir in self.skills_dirs:
138 skill_path = Path(skills_dir) / name / "SKILL.md"
139 if skill_path.exists():
140 skill = self._load_skill_file(skill_path, level)
141 if skill:
142 self._cache[cache_key] = skill
143 return skill
144
145 logger.warning(f"Skill not found: {name}")
146 return None
147
148 def get_skill_content(self, name: str, level: int = 2) -> Optional[str]:
149 """
150 Get skill content at specified disclosure level.
151
152 Level 1: Just the description
153 Level 2: Description + When to Use + Process
154 Level 3: Full content including guidelines
155 """
156 skill = self.load_skill(name, level)
157 if not skill:
158 return None
159
160 if level == 1:
161 return f"# {skill.metadata.name}\n\n{skill.metadata.description}"
162 elif level == 2:
163 parts = [
164 f"# {skill.metadata.name}",
165 f"\n{skill.metadata.description}",
166 ]
167 if skill.when_to_use:
168 parts.append(f"\n## When to Use\n{skill.when_to_use}")
169 if skill.process:
170 parts.append(f"\n## Process\n{skill.process}")
171 return "\n".join(parts)
172 else:
173 return skill.content
174
175 def is_eligible(self, skill_or_name) -> bool:
176 """
177 Check if a skill's requirements are met.
178
179 Checks:
180 - Required config/env vars
181 - Required binaries
182 - Required tools are available
183 """
184 if isinstance(skill_or_name, str):
185 metadata = self._metadata_cache.get(skill_or_name)
186 if not metadata:
187
188 for skills_dir in self.skills_dirs:
189 skill_path = Path(skills_dir) / skill_or_name / "SKILL.md"
190 if skill_path.exists():
191 metadata = self._parse_metadata(skill_path)
192 break
193 if not metadata:
194 return False
195 else:
196 metadata = skill_or_name
197
198 requirements = metadata.requires
199
200 for config_key in requirements.config:
201 if config_key not in self.config and not os.getenv(config_key):
202 logger.debug(f"Missing config: {config_key}")
203 return False
204
205 import shutil
206 for binary in requirements.binaries:
207 if not shutil.which(binary):
208 logger.debug(f"Missing binary: {binary}")
209 return False
210
211 return True
212
213 def _parse_metadata(self, skill_path: Path) -> Optional[SkillMetadata]:
214 """Parse metadata from SKILL.md frontmatter."""
215 try:
216 content = skill_path.read_text(encoding='utf-8')
217
218 frontmatter_match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
219 if not frontmatter_match:
220 logger.warning(f"No frontmatter in {skill_path}")
221 return None
222
223 frontmatter = yaml.safe_load(frontmatter_match.group(1))
224 if not frontmatter:
225 return None
226
227 if 'allowed-tools' in frontmatter and 'requires' not in frontmatter:
228 tools = frontmatter['allowed-tools']
229 if isinstance(tools, str):
230 tools = [t.strip() for t in tools.split(',') if t.strip()]
231 frontmatter['requires'] = {'tools': tools}
232
233 requires_data = frontmatter.get('requires', {})
234 requirements = SkillRequirements(
235 tools=requires_data.get('tools', []) if isinstance(requires_data, dict) else [],
236 config=requires_data.get('config', []) if isinstance(requires_data, dict) else [],
237 binaries=requires_data.get('binaries', []) if isinstance(requires_data, dict) else [],
238 )
239
240 return SkillMetadata(
241 name=frontmatter.get('name', skill_path.parent.name),
242 emoji=frontmatter.get('emoji', ''),
243 description=frontmatter.get('description', ''),
244 requires=requirements,
245 version=frontmatter.get('version', '1.0.0'),
246 author=frontmatter.get('author', ''),
247 tags=frontmatter.get('tags', []),
248 )
249
250 except Exception as e:
251 logger.error(f"Error parsing metadata from {skill_path}: {e}")
252 return None
253
254 def _load_skill_file(self, skill_path: Path, level: int) -> Optional[Skill]:
255 """Load a skill from file."""
256 try:
257 content = skill_path.read_text(encoding='utf-8')
258
259 metadata = self._parse_metadata(skill_path)
260 if not metadata:
261 return None
262
263 content_without_frontmatter = re.sub(
264 r'^---\s*\n.*?\n---\s*\n',
265 '',
266 content,
267 flags=re.DOTALL
268 )
269
270 skill = Skill(
271 metadata=metadata,
272 content=content_without_frontmatter,
273 path=skill_path,
274 )
275
276 skill.when_to_use = self._extract_section(content_without_frontmatter, "When to Use")
277 skill.process = self._extract_section(content_without_frontmatter, "Process")
278 skill.output_format = self._extract_section(content_without_frontmatter, "Output Format")
279 skill.guidelines = self._extract_section(content_without_frontmatter, "Guidelines")
280
281 return skill
282
283 except Exception as e:
284 logger.error(f"Error loading skill from {skill_path}: {e}")
285 return None
286
287 def _extract_section(self, content: str, section_name: str) -> str:
288 """Extract a section from markdown content."""
289 pattern = rf'##\s+{re.escape(section_name)}\s*\n(.*?)(?=\n##\s|\Z)'
290 match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
291 return match.group(1).strip() if match else ""
292
293 def get_tools_for_skill(self, name: str) -> List[str]:
294 """Get the list of tools required by a skill."""
295 metadata = self._metadata_cache.get(name)
296 if not metadata:
297 skill = self.load_skill(name)
298 if skill:
299 metadata = skill.metadata
300
301 return metadata.requires.tools if metadata else []
302
303def create_skill_loader(skills_dir: str = None, config: dict = None) -> SkillLoader:
304 """Create a SkillLoader with default settings."""
305 loader = SkillLoader(config=config)
306
307 if skills_dir:
308 loader.add_skills_dir(skills_dir)
309 else:
310
311 default_dir = Path(__file__).parent
312 loader.add_skills_dir(str(default_dir))
313
314 return loader
315