main
py 301 lines 9.62 KB
Raw
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