| 1 | from __future__ import annotations |
| 2 | |
| 3 | from pathlib import Path |
| 4 | from typing import List |
| 5 | |
| 6 | from helpers.tool import Tool, Response |
| 7 | from helpers import skills as skills_helper |
| 8 | from helpers.print_style import PrintStyle |
| 9 | |
| 10 | |
| 11 | DATA_NAME_LOADED_SKILLS = skills_helper.CONTEXT_DATA_NAME_LOADED_SKILLS |
| 12 | |
| 13 | |
| 14 | class SkillsTool(Tool): |
| 15 | """ |
| 16 | Manage and use SKILL.md-based Skills (Anthropic open standard). |
| 17 | |
| 18 | Actions (tool_args.action): |
| 19 | - list |
| 20 | - search (query) |
| 21 | - load (skill_name) |
| 22 | - read_file (skill_name, file_path) |
| 23 | |
| 24 | Script execution is handled by code_execution_tool directly. |
| 25 | """ |
| 26 | |
| 27 | @staticmethod |
| 28 | def _normalize_action(action: object) -> str: |
| 29 | return ( |
| 30 | str( |
| 31 | action |
| 32 | or "list" |
| 33 | ) |
| 34 | .strip() |
| 35 | .lower() |
| 36 | .replace("-", "_") |
| 37 | ) |
| 38 | |
| 39 | def _current_action(self, **kwargs) -> str: |
| 40 | return self._normalize_action( |
| 41 | kwargs.get("action") |
| 42 | or self.args.get("action") |
| 43 | or kwargs.get("method") |
| 44 | or self.args.get("method") |
| 45 | ) |
| 46 | |
| 47 | @staticmethod |
| 48 | def _normalize_skill_name(skill_name: str) -> str: |
| 49 | skill_name = skill_name.strip() |
| 50 | if skill_name.startswith("**") and skill_name.endswith("**"): |
| 51 | skill_name = skill_name[2:-2] |
| 52 | return skill_name.strip() |
| 53 | |
| 54 | def get_log_object(self): |
| 55 | import uuid |
| 56 | |
| 57 | if self._current_action() == "load": |
| 58 | skill_name = self._normalize_skill_name( |
| 59 | str(self.args.get("skill_name") or "") |
| 60 | ) |
| 61 | heading = ( |
| 62 | f"icon://construction Loading skill {skill_name}" |
| 63 | if skill_name |
| 64 | else "icon://construction Loading skill" |
| 65 | ) |
| 66 | return self.agent.context.log.log( |
| 67 | type="tool", |
| 68 | heading=heading, |
| 69 | content="", |
| 70 | kvps={"_tool_name": self.name}, |
| 71 | id=str(uuid.uuid4()), |
| 72 | ) |
| 73 | |
| 74 | return super().get_log_object() |
| 75 | |
| 76 | async def before_execution(self, **kwargs): |
| 77 | if self._current_action(**kwargs) != "load": |
| 78 | await super().before_execution(**kwargs) |
| 79 | return |
| 80 | |
| 81 | skill_name = self._normalize_skill_name( |
| 82 | str(kwargs.get("skill_name") or self.args.get("skill_name") or "") |
| 83 | ) |
| 84 | label = f"{self.name} action {self._current_action(**kwargs)}" |
| 85 | if skill_name: |
| 86 | PrintStyle( |
| 87 | font_color="#1B4F72", |
| 88 | padding=True, |
| 89 | background_color="white", |
| 90 | bold=True, |
| 91 | ).print(f"{self.agent.agent_name}: Loading skill '{skill_name}'") |
| 92 | else: |
| 93 | PrintStyle( |
| 94 | font_color="#1B4F72", |
| 95 | padding=True, |
| 96 | background_color="white", |
| 97 | bold=True, |
| 98 | ).print(f"{self.agent.agent_name}: Using tool '{label}'") |
| 99 | self.log = self.get_log_object() |
| 100 | |
| 101 | async def execute(self, **kwargs) -> Response: |
| 102 | action = self._current_action(**kwargs) |
| 103 | |
| 104 | query = str(kwargs.get("query") or self.args.get("query") or "").strip() |
| 105 | skill_name = self._normalize_skill_name( |
| 106 | str(kwargs.get("skill_name") or self.args.get("skill_name") or "") |
| 107 | ) |
| 108 | file_path = str( |
| 109 | kwargs.get("file_path") or self.args.get("file_path") or "" |
| 110 | ).strip() |
| 111 | |
| 112 | if "action" not in kwargs and "action" not in self.args and "method" in kwargs: |
| 113 | kwargs["action"] = action |
| 114 | if "action" not in self.args and "method" in self.args: |
| 115 | self.args["action"] = action |
| 116 | |
| 117 | try: |
| 118 | if action == "list": |
| 119 | return Response(message=self._list(), break_loop=False) |
| 120 | if action == "search": |
| 121 | return Response(message=self._search(query), break_loop=False) |
| 122 | if action == "load": |
| 123 | return self._load(skill_name) |
| 124 | if action == "read_file": |
| 125 | return Response( |
| 126 | message=self._read_file(skill_name, file_path), |
| 127 | break_loop=False, |
| 128 | ) |
| 129 | |
| 130 | return Response( |
| 131 | message=( |
| 132 | "Error: missing/invalid 'action'. Supported actions: " |
| 133 | "list, search, load, read_file." |
| 134 | ), |
| 135 | break_loop=False, |
| 136 | ) |
| 137 | except ( |
| 138 | Exception |
| 139 | ) as e: # keep tool robust; return error instead of crashing loop |
| 140 | return Response(message=f"Error in skills_tool: {e}", break_loop=False) |
| 141 | |
| 142 | def _list(self) -> str: |
| 143 | skills = skills_helper.list_skills( |
| 144 | agent=self.agent, |
| 145 | include_content=False, |
| 146 | ) |
| 147 | if not skills: |
| 148 | return "No skills found." |
| 149 | |
| 150 | skills_sorted = sorted(skills, key=lambda s: s.name.lower()) |
| 151 | lines = [f"Available skills ({len(skills_sorted)}):"] |
| 152 | for s in skills_sorted: |
| 153 | tags = f" tags={','.join(s.tags)}" if s.tags else "" |
| 154 | ver = f" v{s.version}" if s.version else "" |
| 155 | desc = (s.description or "").strip() |
| 156 | if len(desc) > 200: |
| 157 | desc = desc[:200].rstrip() + "..." |
| 158 | lines.append(f"- {s.name}{ver}{tags}: {desc}") |
| 159 | lines.append("") |
| 160 | lines.append("Tip: use skills_tool action=search or action=load for details.") |
| 161 | return "\n".join(lines) |
| 162 | |
| 163 | def _search(self, query: str) -> str: |
| 164 | if not query: |
| 165 | return "Error: 'query' is required for action=search." |
| 166 | |
| 167 | results = skills_helper.search_skills( |
| 168 | query, |
| 169 | limit=25, |
| 170 | agent=self.agent, |
| 171 | ) |
| 172 | if not results: |
| 173 | return f"No skills matched query: {query!r}" |
| 174 | |
| 175 | lines: List[str] = [] |
| 176 | lines.append(f"Skills matching {query!r} ({len(results)}):") |
| 177 | for s in results: |
| 178 | desc = (s.description or "").strip() |
| 179 | if len(desc) > 200: |
| 180 | desc = desc[:200].rstrip() + "…" |
| 181 | lines.append(f"- {s.name}: {desc}") |
| 182 | lines.append("") |
| 183 | lines.append( |
| 184 | "Tip: use skills_tool action=load skill_name=<name> to load full instructions." |
| 185 | ) |
| 186 | return "\n".join(lines) |
| 187 | |
| 188 | def _load(self, skill_name: str) -> Response: |
| 189 | skill_name = self._normalize_skill_name(skill_name) |
| 190 | |
| 191 | if not skill_name: |
| 192 | return Response( |
| 193 | message="Error: 'skill_name' is required for action=load.", |
| 194 | break_loop=False, |
| 195 | ) |
| 196 | |
| 197 | # Verify skill exists |
| 198 | skill = skills_helper.find_skill( |
| 199 | skill_name, |
| 200 | include_content=False, |
| 201 | agent=self.agent, |
| 202 | ) |
| 203 | if not skill: |
| 204 | return Response( |
| 205 | message=( |
| 206 | f"Error: skill not found: {skill_name!r}. " |
| 207 | "Try skills_tool action=list or action=search." |
| 208 | ), |
| 209 | break_loop=False, |
| 210 | ) |
| 211 | |
| 212 | skill_data = skills_helper.load_skill_for_agent( |
| 213 | skill_name=skill.name, |
| 214 | agent=self.agent, |
| 215 | ) |
| 216 | metadata = { |
| 217 | "name": skill.name, |
| 218 | "path": str(skill.path), |
| 219 | "source": "skills_tool:load", |
| 220 | "content_included": True, |
| 221 | } |
| 222 | |
| 223 | skills_helper.add_loaded_skill_name( |
| 224 | self.agent, |
| 225 | skill.name, |
| 226 | limit=max_loaded_skills(), |
| 227 | ) |
| 228 | |
| 229 | if self._visible_skill_loaded(skill.name): |
| 230 | return Response( |
| 231 | message=( |
| 232 | f"Skill '{skill.name}' is already loaded in visible " |
| 233 | "chat history." |
| 234 | ), |
| 235 | break_loop=False, |
| 236 | additional={ |
| 237 | "skill_instructions": { |
| 238 | **metadata, |
| 239 | "content_included": False, |
| 240 | "already_loaded": True, |
| 241 | } |
| 242 | }, |
| 243 | ) |
| 244 | |
| 245 | return Response( |
| 246 | message=skill_data, |
| 247 | break_loop=False, |
| 248 | additional={"skill_instructions": metadata}, |
| 249 | ) |
| 250 | |
| 251 | def _visible_skill_loaded(self, skill_name: str) -> bool: |
| 252 | history_obj = getattr(self.agent, "history", None) |
| 253 | output = getattr(history_obj, "output", None) |
| 254 | if not callable(output): |
| 255 | return False |
| 256 | |
| 257 | return any( |
| 258 | skills_helper.skill_instruction_name(message) == skill_name |
| 259 | for message in output() |
| 260 | ) |
| 261 | |
| 262 | def _read_file(self, skill_name: str, file_path: str) -> str: |
| 263 | if not skill_name: |
| 264 | return "Error: 'skill_name' is required for action=read_file." |
| 265 | if not file_path: |
| 266 | return "Error: 'file_path' is required for action=read_file." |
| 267 | |
| 268 | skill = skills_helper.find_skill( |
| 269 | skill_name, |
| 270 | include_content=False, |
| 271 | agent=self.agent, |
| 272 | ) |
| 273 | if not skill: |
| 274 | return f"Error: skill not found: {skill_name!r}." |
| 275 | |
| 276 | skill_root = skill.path.resolve() |
| 277 | target = Path(file_path) |
| 278 | if not target.is_absolute(): |
| 279 | target = skill_root / target |
| 280 | |
| 281 | try: |
| 282 | resolved = target.resolve() |
| 283 | resolved.relative_to(skill_root) |
| 284 | except Exception: |
| 285 | return "Error: file_path must stay inside the skill directory." |
| 286 | |
| 287 | if not resolved.is_file(): |
| 288 | return f"Error: skill file not found: {file_path!r}." |
| 289 | |
| 290 | content = resolved.read_text(encoding="utf-8", errors="replace") |
| 291 | if len(content) > 24000: |
| 292 | content = content[:24000].rstrip() + "\n\n[truncated]" |
| 293 | |
| 294 | return ( |
| 295 | f"Skill file: {skill.name}/{resolved.relative_to(skill_root)}\n\n" |
| 296 | f"{content}" |
| 297 | ) |
| 298 | |
| 299 | |
| 300 | def max_loaded_skills() -> int: |
| 301 | return skills_helper.MAX_ACTIVE_SKILLS |