openhands-sdk 1.7.3__py3-none-any.whl → 1.8.0__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 (44) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +31 -1
  3. openhands/sdk/agent/base.py +111 -67
  4. openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
  5. openhands/sdk/agent/utils.py +3 -0
  6. openhands/sdk/context/agent_context.py +45 -3
  7. openhands/sdk/context/condenser/__init__.py +2 -0
  8. openhands/sdk/context/condenser/base.py +59 -8
  9. openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
  10. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  11. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  12. openhands/sdk/context/skills/__init__.py +12 -0
  13. openhands/sdk/context/skills/skill.py +425 -228
  14. openhands/sdk/context/skills/types.py +4 -0
  15. openhands/sdk/context/skills/utils.py +442 -0
  16. openhands/sdk/context/view.py +2 -0
  17. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  18. openhands/sdk/conversation/impl/remote_conversation.py +99 -55
  19. openhands/sdk/conversation/state.py +54 -18
  20. openhands/sdk/event/llm_convertible/action.py +20 -0
  21. openhands/sdk/git/utils.py +31 -6
  22. openhands/sdk/hooks/conversation_hooks.py +57 -10
  23. openhands/sdk/llm/llm.py +59 -76
  24. openhands/sdk/llm/options/chat_options.py +4 -1
  25. openhands/sdk/llm/router/base.py +12 -0
  26. openhands/sdk/llm/utils/telemetry.py +2 -2
  27. openhands/sdk/llm/utils/verified_models.py +1 -1
  28. openhands/sdk/mcp/tool.py +3 -1
  29. openhands/sdk/plugin/__init__.py +22 -0
  30. openhands/sdk/plugin/plugin.py +299 -0
  31. openhands/sdk/plugin/types.py +226 -0
  32. openhands/sdk/tool/__init__.py +7 -1
  33. openhands/sdk/tool/builtins/__init__.py +4 -0
  34. openhands/sdk/tool/schema.py +6 -3
  35. openhands/sdk/tool/tool.py +60 -9
  36. openhands/sdk/utils/models.py +198 -472
  37. openhands/sdk/workspace/base.py +22 -0
  38. openhands/sdk/workspace/local.py +16 -0
  39. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  40. openhands/sdk/workspace/remote/base.py +16 -0
  41. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
  42. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
  43. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  44. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
@@ -1,21 +1,31 @@
1
1
  import io
2
2
  import re
3
- import shutil
4
- import subprocess
5
- from itertools import chain
6
3
  from pathlib import Path
7
4
  from typing import Annotated, ClassVar, Union
5
+ from xml.sax.saxutils import escape as xml_escape
8
6
 
9
7
  import frontmatter
10
8
  from fastmcp.mcp_config import MCPConfig
11
9
  from pydantic import BaseModel, Field, field_validator, model_validator
12
10
 
13
- from openhands.sdk.context.skills.exceptions import SkillValidationError
11
+ from openhands.sdk.context.skills.exceptions import SkillError, SkillValidationError
14
12
  from openhands.sdk.context.skills.trigger import (
15
13
  KeywordTrigger,
16
14
  TaskTrigger,
17
15
  )
18
16
  from openhands.sdk.context.skills.types import InputMetadata
17
+ from openhands.sdk.context.skills.utils import (
18
+ discover_skill_resources,
19
+ find_mcp_config,
20
+ find_regular_md_files,
21
+ find_skill_md_directories,
22
+ find_third_party_files,
23
+ get_skills_cache_dir,
24
+ load_and_categorize,
25
+ load_mcp_config,
26
+ update_skills_repository,
27
+ validate_skill_name,
28
+ )
19
29
  from openhands.sdk.logger import get_logger
20
30
  from openhands.sdk.utils import maybe_truncate
21
31
 
@@ -26,6 +36,50 @@ logger = get_logger(__name__)
26
36
  # These files are always active, so we want to keep them reasonably sized
27
37
  THIRD_PARTY_SKILL_MAX_CHARS = 10_000
28
38
 
39
+
40
+ class SkillResources(BaseModel):
41
+ """Resource directories for a skill (AgentSkills standard).
42
+
43
+ Per the AgentSkills specification, skills can include:
44
+ - scripts/: Executable scripts the agent can run
45
+ - references/: Reference documentation and examples
46
+ - assets/: Static assets (images, data files, etc.)
47
+ """
48
+
49
+ skill_root: str = Field(description="Root directory of the skill (absolute path)")
50
+ scripts: list[str] = Field(
51
+ default_factory=list,
52
+ description="List of script files in scripts/ directory (relative paths)",
53
+ )
54
+ references: list[str] = Field(
55
+ default_factory=list,
56
+ description="List of reference files in references/ directory (relative paths)",
57
+ )
58
+ assets: list[str] = Field(
59
+ default_factory=list,
60
+ description="List of asset files in assets/ directory (relative paths)",
61
+ )
62
+
63
+ def has_resources(self) -> bool:
64
+ """Check if any resources are available."""
65
+ return bool(self.scripts or self.references or self.assets)
66
+
67
+ def get_scripts_dir(self) -> Path | None:
68
+ """Get the scripts directory path if it exists."""
69
+ scripts_dir = Path(self.skill_root) / "scripts"
70
+ return scripts_dir if scripts_dir.is_dir() else None
71
+
72
+ def get_references_dir(self) -> Path | None:
73
+ """Get the references directory path if it exists."""
74
+ refs_dir = Path(self.skill_root) / "references"
75
+ return refs_dir if refs_dir.is_dir() else None
76
+
77
+ def get_assets_dir(self) -> Path | None:
78
+ """Get the assets directory path if it exists."""
79
+ assets_dir = Path(self.skill_root) / "assets"
80
+ return assets_dir if assets_dir.is_dir() else None
81
+
82
+
29
83
  # Union type for all trigger types
30
84
  TriggerType = Annotated[
31
85
  KeywordTrigger | TaskTrigger,
@@ -36,10 +90,16 @@ TriggerType = Annotated[
36
90
  class Skill(BaseModel):
37
91
  """A skill provides specialized knowledge or functionality.
38
92
 
39
- Skills use triggers to determine when they should be activated:
40
- - None: Always active, for repository-specific guidelines
41
- - KeywordTrigger: Activated when keywords appear in user messages
42
- - TaskTrigger: Activated for specific tasks, may require user input
93
+ Skill behavior depends on format (is_agentskills_format) and trigger:
94
+
95
+ AgentSkills format (SKILL.md files):
96
+ - Always listed in <available_skills> with name, description, location
97
+ - Agent reads full content on demand (progressive disclosure)
98
+ - If has triggers: content is ALSO auto-injected when triggered
99
+
100
+ Legacy OpenHands format:
101
+ - With triggers: Listed in <available_skills>, content injected on trigger
102
+ - Without triggers (None): Full content in <REPO_CONTEXT>, always active
43
103
 
44
104
  This model supports both OpenHands-specific fields and AgentSkills standard
45
105
  fields (https://agentskills.io/specification) for cross-platform compatibility.
@@ -50,11 +110,11 @@ class Skill(BaseModel):
50
110
  trigger: TriggerType | None = Field(
51
111
  default=None,
52
112
  description=(
53
- "Skills use triggers to determine when they should be activated. "
54
- "None implies skill is always active. "
55
- "Other implementations include KeywordTrigger (activated by a "
56
- "keyword in a Message) and TaskTrigger (activated by specific tasks "
57
- "and may require user input)"
113
+ "Trigger determines when skill content is auto-injected. "
114
+ "None = no auto-injection (for AgentSkills: agent reads on demand; "
115
+ "for legacy: full content always in system prompt). "
116
+ "KeywordTrigger = auto-inject when keywords appear in user messages. "
117
+ "TaskTrigger = auto-inject for specific tasks, may require user input."
58
118
  ),
59
119
  )
60
120
  source: str | None = Field(
@@ -76,6 +136,16 @@ class Skill(BaseModel):
76
136
  default_factory=list,
77
137
  description="Input metadata for the skill (task skills only)",
78
138
  )
139
+ is_agentskills_format: bool = Field(
140
+ default=False,
141
+ description=(
142
+ "Whether this skill was loaded from a SKILL.md file following the "
143
+ "AgentSkills standard. AgentSkills-format skills use progressive "
144
+ "disclosure: always listed in <available_skills> with name, "
145
+ "description, and location. If the skill also has triggers, content "
146
+ "is auto-injected when triggered AND agent can read file anytime."
147
+ ),
148
+ )
79
149
 
80
150
  # AgentSkills standard fields (https://agentskills.io/specification)
81
151
  description: str | None = Field(
@@ -113,6 +183,23 @@ class Skill(BaseModel):
113
183
  "AgentSkills standard field (parsed from space-delimited string)."
114
184
  ),
115
185
  )
186
+ resources: SkillResources | None = Field(
187
+ default=None,
188
+ description=(
189
+ "Resource directories for the skill (scripts/, references/, assets/). "
190
+ "AgentSkills standard field. Only populated for SKILL.md directory format."
191
+ ),
192
+ )
193
+
194
+ @field_validator("description")
195
+ @classmethod
196
+ def _validate_description_length(cls, v: str | None) -> str | None:
197
+ """Validate description length per AgentSkills spec (max 1024 chars)."""
198
+ if v is not None and len(v) > 1024:
199
+ raise SkillValidationError(
200
+ f"Description exceeds 1024 characters ({len(v)} chars)"
201
+ )
202
+ return v
116
203
 
117
204
  @field_validator("allowed_tools", mode="before")
118
205
  @classmethod
@@ -136,6 +223,19 @@ class Skill(BaseModel):
136
223
  return {str(k): str(val) for k, val in v.items()}
137
224
  raise SkillValidationError("metadata must be a dictionary")
138
225
 
226
+ @field_validator("mcp_tools")
227
+ @classmethod
228
+ def _validate_mcp_tools(cls, v: dict | None, _info):
229
+ """Validate mcp_tools conforms to MCPConfig schema."""
230
+ if v is None:
231
+ return v
232
+ if isinstance(v, dict):
233
+ try:
234
+ MCPConfig.model_validate(v)
235
+ except Exception as e:
236
+ raise SkillValidationError(f"Invalid MCPConfig dictionary: {e}") from e
237
+ return v
238
+
139
239
  PATH_TO_THIRD_PARTY_SKILL_NAME: ClassVar[dict[str, str]] = {
140
240
  ".cursorrules": "cursorrules",
141
241
  "agents.md": "agents",
@@ -144,89 +244,153 @@ class Skill(BaseModel):
144
244
  "gemini.md": "gemini",
145
245
  }
146
246
 
147
- @classmethod
148
- def _handle_third_party(cls, path: Path, file_content: str) -> Union["Skill", None]:
149
- # Determine the agent name based on file type
150
- skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(path.name.lower())
151
-
152
- # Create Skill with None trigger (always active) if we recognized the file type
153
- if skill_name is not None:
154
- # Truncate content if it exceeds the limit
155
- # Third-party files are always active, so we want to keep them
156
- # reasonably sized
157
- truncated_content = maybe_truncate(
158
- file_content,
159
- truncate_after=THIRD_PARTY_SKILL_MAX_CHARS,
160
- truncate_notice=(
161
- f"\n\n<TRUNCATED><NOTE>The file {path} exceeded the "
162
- f"maximum length ({THIRD_PARTY_SKILL_MAX_CHARS} "
163
- f"characters) and has been truncated. Only the "
164
- f"beginning and end are shown. You can read the full "
165
- f"file if needed.</NOTE>\n\n"
166
- ),
167
- )
168
-
169
- if len(file_content) > THIRD_PARTY_SKILL_MAX_CHARS:
170
- logger.warning(
171
- f"Third-party skill file {path} ({len(file_content)} chars) "
172
- f"exceeded limit ({THIRD_PARTY_SKILL_MAX_CHARS} chars), truncating"
173
- )
174
-
175
- return Skill(
176
- name=skill_name,
177
- content=truncated_content,
178
- source=str(path),
179
- trigger=None,
180
- )
181
-
182
- return None
183
-
184
247
  @classmethod
185
248
  def load(
186
249
  cls,
187
250
  path: str | Path,
188
- skill_dir: Path | None = None,
189
- file_content: str | None = None,
251
+ skill_base_dir: Path | None = None,
252
+ strict: bool = True,
190
253
  ) -> "Skill":
191
254
  """Load a skill from a markdown file with frontmatter.
192
255
 
193
- The agent's name is derived from its path relative to the skill_dir.
256
+ The agent's name is derived from its path relative to skill_base_dir,
257
+ or from the directory name for AgentSkills-style SKILL.md files.
194
258
 
195
259
  Supports both OpenHands-specific frontmatter fields and AgentSkills
196
260
  standard fields (https://agentskills.io/specification).
261
+
262
+ Args:
263
+ path: Path to the skill file.
264
+ skill_base_dir: Base directory for skills (used to derive relative names).
265
+ strict: If True, enforce strict AgentSkills name validation.
266
+ If False, allow relaxed naming (e.g., for plugin compatibility).
197
267
  """
198
268
  path = Path(path) if isinstance(path, str) else path
199
269
 
200
- # Calculate derived name from relative path if skill_dir is provided
201
- skill_name = None
202
- if skill_dir is not None:
203
- # Special handling for files which are not in skill_dir
204
- skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(
205
- path.name.lower()
206
- ) or str(path.relative_to(skill_dir).with_suffix(""))
270
+ with open(path) as f:
271
+ file_content = f.read()
272
+
273
+ if path.name.lower() == "skill.md":
274
+ return cls._load_agentskills_skill(path, file_content, strict=strict)
207
275
  else:
208
- skill_name = path.stem
276
+ return cls._load_legacy_openhands_skill(path, file_content, skill_base_dir)
209
277
 
210
- # Only load directly from path if file_content is not provided
211
- if file_content is None:
212
- with open(path) as f:
213
- file_content = f.read()
278
+ @classmethod
279
+ def _load_agentskills_skill(
280
+ cls, path: Path, file_content: str, strict: bool = True
281
+ ) -> "Skill":
282
+ """Load a skill from an AgentSkills-format SKILL.md file.
283
+
284
+ Args:
285
+ path: Path to the SKILL.md file.
286
+ file_content: Content of the file.
287
+ strict: If True, enforce strict AgentSkills name validation.
288
+ """
289
+ # For SKILL.md files, use parent directory name as the skill name
290
+ directory_name = path.parent.name
291
+ skill_root = path.parent
292
+
293
+ file_io = io.StringIO(file_content)
294
+ loaded = frontmatter.load(file_io)
295
+ content = loaded.content
296
+ metadata_dict = loaded.metadata or {}
297
+
298
+ # Use name from frontmatter if provided, otherwise use directory name
299
+ agent_name = str(metadata_dict.get("name", directory_name))
300
+
301
+ # Validate skill name (only in strict mode)
302
+ if strict:
303
+ name_errors = validate_skill_name(agent_name, directory_name)
304
+ if name_errors:
305
+ raise SkillValidationError(
306
+ f"Invalid skill name '{agent_name}': {'; '.join(name_errors)}"
307
+ )
308
+
309
+ # Load MCP configuration from .mcp.json (agent_skills ONLY use .mcp.json)
310
+ mcp_tools: dict | None = None
311
+ mcp_json_path = find_mcp_config(skill_root)
312
+ if mcp_json_path:
313
+ mcp_tools = load_mcp_config(mcp_json_path, skill_root)
314
+
315
+ # Discover resource directories
316
+ resources: SkillResources | None = None
317
+ discovered_resources = discover_skill_resources(skill_root)
318
+ if discovered_resources.has_resources():
319
+ resources = discovered_resources
320
+
321
+ return cls._create_skill_from_metadata(
322
+ agent_name,
323
+ content,
324
+ path,
325
+ metadata_dict,
326
+ mcp_tools,
327
+ resources=resources,
328
+ is_agentskills_format=True,
329
+ )
330
+
331
+ @classmethod
332
+ def _load_legacy_openhands_skill(
333
+ cls, path: Path, file_content: str, skill_base_dir: Path | None
334
+ ) -> "Skill":
335
+ """Load a skill from a legacy OpenHands-format file.
214
336
 
337
+ Args:
338
+ path: Path to the skill file.
339
+ file_content: Content of the file.
340
+ skill_base_dir: Base directory for skills (used to derive relative names).
341
+ """
215
342
  # Handle third-party agent instruction files
216
343
  third_party_agent = cls._handle_third_party(path, file_content)
217
344
  if third_party_agent is not None:
218
345
  return third_party_agent
219
346
 
347
+ # Calculate derived name from path
348
+ if skill_base_dir is not None:
349
+ skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(
350
+ path.name.lower()
351
+ ) or str(path.relative_to(skill_base_dir).with_suffix(""))
352
+ else:
353
+ skill_name = path.stem
354
+
220
355
  file_io = io.StringIO(file_content)
221
356
  loaded = frontmatter.load(file_io)
222
357
  content = loaded.content
223
-
224
- # Handle case where there's no frontmatter or empty frontmatter
225
358
  metadata_dict = loaded.metadata or {}
226
359
 
227
360
  # Use name from frontmatter if provided, otherwise use derived name
228
361
  agent_name = str(metadata_dict.get("name", skill_name))
229
362
 
363
+ # Legacy skills ONLY use mcp_tools from frontmatter (not .mcp.json)
364
+ mcp_tools = metadata_dict.get("mcp_tools")
365
+ if mcp_tools is not None and not isinstance(mcp_tools, dict):
366
+ raise SkillValidationError("mcp_tools must be a dictionary or None")
367
+
368
+ return cls._create_skill_from_metadata(
369
+ agent_name, content, path, metadata_dict, mcp_tools
370
+ )
371
+
372
+ @classmethod
373
+ def _create_skill_from_metadata(
374
+ cls,
375
+ agent_name: str,
376
+ content: str,
377
+ path: Path,
378
+ metadata_dict: dict,
379
+ mcp_tools: dict | None = None,
380
+ resources: SkillResources | None = None,
381
+ is_agentskills_format: bool = False,
382
+ ) -> "Skill":
383
+ """Create a Skill object from parsed metadata.
384
+
385
+ Args:
386
+ agent_name: The name of the skill.
387
+ content: The markdown content (without frontmatter).
388
+ path: Path to the skill file.
389
+ metadata_dict: Parsed frontmatter metadata.
390
+ mcp_tools: MCP tools configuration (from .mcp.json or frontmatter).
391
+ resources: Discovered resource directories.
392
+ is_agentskills_format: Whether this skill follows the AgentSkills standard.
393
+ """
230
394
  # Extract AgentSkills standard fields (Pydantic validators handle
231
395
  # transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
232
396
  allowed_tools_value = metadata_dict.get(
@@ -270,6 +434,9 @@ class Skill(BaseModel):
270
434
  source=str(path),
271
435
  trigger=TaskTrigger(triggers=keywords),
272
436
  inputs=inputs,
437
+ mcp_tools=mcp_tools,
438
+ resources=resources,
439
+ is_agentskills_format=is_agentskills_format,
273
440
  **agentskills_fields,
274
441
  )
275
442
 
@@ -279,34 +446,60 @@ class Skill(BaseModel):
279
446
  content=content,
280
447
  source=str(path),
281
448
  trigger=KeywordTrigger(keywords=keywords),
449
+ mcp_tools=mcp_tools,
450
+ resources=resources,
451
+ is_agentskills_format=is_agentskills_format,
282
452
  **agentskills_fields,
283
453
  )
284
454
  else:
285
455
  # No triggers, default to None (always active)
286
- mcp_tools = metadata_dict.get("mcp_tools")
287
- if not isinstance(mcp_tools, dict | None):
288
- raise SkillValidationError("mcp_tools must be a dictionary or None")
289
456
  return Skill(
290
457
  name=agent_name,
291
458
  content=content,
292
459
  source=str(path),
293
460
  trigger=None,
294
461
  mcp_tools=mcp_tools,
462
+ resources=resources,
463
+ is_agentskills_format=is_agentskills_format,
295
464
  **agentskills_fields,
296
465
  )
297
466
 
298
- # Field-level validation for mcp_tools
299
- @field_validator("mcp_tools")
300
467
  @classmethod
301
- def _validate_mcp_tools(cls, v: dict | None, _info):
302
- if v is None:
303
- return v
304
- if isinstance(v, dict):
305
- try:
306
- MCPConfig.model_validate(v)
307
- except Exception as e:
308
- raise SkillValidationError(f"Invalid MCPConfig dictionary: {e}") from e
309
- return v
468
+ def _handle_third_party(cls, path: Path, file_content: str) -> Union["Skill", None]:
469
+ """Handle third-party skill files (e.g., .cursorrules, AGENTS.md).
470
+
471
+ Creates a Skill with None trigger (always active) if the file type
472
+ is recognized. Truncates content if it exceeds the limit.
473
+ """
474
+ skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(path.name.lower())
475
+
476
+ if skill_name is not None:
477
+ truncated_content = maybe_truncate(
478
+ file_content,
479
+ truncate_after=THIRD_PARTY_SKILL_MAX_CHARS,
480
+ truncate_notice=(
481
+ f"\n\n<TRUNCATED><NOTE>The file {path} exceeded the "
482
+ f"maximum length ({THIRD_PARTY_SKILL_MAX_CHARS} "
483
+ f"characters) and has been truncated. Only the "
484
+ f"beginning and end are shown. You can read the full "
485
+ f"file if needed.</NOTE>\n\n"
486
+ ),
487
+ )
488
+
489
+ if len(file_content) > THIRD_PARTY_SKILL_MAX_CHARS:
490
+ logger.warning(
491
+ f"Third-party skill file {path} ({len(file_content)} chars) "
492
+ f"exceeded limit ({THIRD_PARTY_SKILL_MAX_CHARS} chars), truncating"
493
+ )
494
+
495
+ return Skill(
496
+ name=skill_name,
497
+ content=truncated_content,
498
+ source=str(path),
499
+ trigger=None,
500
+ )
501
+
502
+ return None
310
503
 
311
504
  @model_validator(mode="after")
312
505
  def _append_missing_variables_prompt(self):
@@ -370,70 +563,60 @@ class Skill(BaseModel):
370
563
 
371
564
  def load_skills_from_dir(
372
565
  skill_dir: str | Path,
373
- ) -> tuple[dict[str, Skill], dict[str, Skill]]:
566
+ ) -> tuple[dict[str, Skill], dict[str, Skill], dict[str, Skill]]:
374
567
  """Load all skills from the given directory.
375
568
 
569
+ Supports both formats:
570
+ - OpenHands format: skills/*.md files
571
+ - AgentSkills format: skills/skill-name/SKILL.md directories
572
+
376
573
  Note, legacy repo instructions will not be loaded here.
377
574
 
378
575
  Args:
379
576
  skill_dir: Path to the skills directory (e.g. .openhands/skills)
380
577
 
381
578
  Returns:
382
- Tuple of (repo_skills, knowledge_skills) dictionaries.
383
- repo_skills have trigger=None, knowledge_skills have KeywordTrigger
384
- or TaskTrigger.
579
+ Tuple of (repo_skills, knowledge_skills, agent_skills) dictionaries.
580
+ - repo_skills: Skills with trigger=None (permanent context)
581
+ - knowledge_skills: Skills with KeywordTrigger or TaskTrigger (progressive)
582
+ - agent_skills: AgentSkills standard SKILL.md files (separate category)
385
583
  """
386
584
  if isinstance(skill_dir, str):
387
585
  skill_dir = Path(skill_dir)
388
586
 
389
- repo_skills = {}
390
- knowledge_skills = {}
391
-
392
- # Load all agents from skills directory
587
+ repo_skills: dict[str, Skill] = {}
588
+ knowledge_skills: dict[str, Skill] = {}
589
+ agent_skills: dict[str, Skill] = {}
393
590
  logger.debug(f"Loading agents from {skill_dir}")
394
591
 
395
- # Always check for .cursorrules and AGENTS.md files in repo root
396
- special_files = []
397
- repo_root = skill_dir.parent.parent
398
-
399
- # Check for third party rules: .cursorrules, AGENTS.md, etc
400
- for filename in Skill.PATH_TO_THIRD_PARTY_SKILL_NAME.keys():
401
- for variant in [filename, filename.lower(), filename.upper()]:
402
- if (repo_root / variant).exists():
403
- special_files.append(repo_root / variant)
404
- break # Only add the first one found to avoid duplicates
405
-
406
- # Collect .md files from skills directory if it exists
407
- md_files = []
408
- if skill_dir.exists():
409
- md_files = [f for f in skill_dir.rglob("*.md") if f.name != "README.md"]
592
+ # Discover skill files in the skills directory
593
+ # Note: Third-party files (AGENTS.md, etc.) are loaded separately by
594
+ # load_project_skills() to ensure they're loaded even when this directory
595
+ # doesn't exist.
596
+ skill_md_files = find_skill_md_directories(skill_dir)
597
+ skill_md_dirs = {skill_md.parent for skill_md in skill_md_files}
598
+ regular_md_files = find_regular_md_files(skill_dir, skill_md_dirs)
599
+
600
+ # Load SKILL.md files (auto-detected and validated in Skill.load)
601
+ for skill_md_path in skill_md_files:
602
+ load_and_categorize(
603
+ skill_md_path, skill_dir, repo_skills, knowledge_skills, agent_skills
604
+ )
410
605
 
411
- # Process all files in one loop
412
- for file in chain(special_files, md_files):
413
- try:
414
- skill = Skill.load(
415
- file,
416
- skill_dir,
417
- )
418
- if skill.trigger is None:
419
- repo_skills[skill.name] = skill
420
- else:
421
- # KeywordTrigger and TaskTrigger skills
422
- knowledge_skills[skill.name] = skill
423
- except SkillValidationError as e:
424
- # For validation errors, include the original exception
425
- error_msg = f"Error loading skill from {file}: {str(e)}"
426
- raise SkillValidationError(error_msg) from e
427
- except Exception as e:
428
- # For other errors, wrap in a ValueError with detailed message
429
- error_msg = f"Error loading skill from {file}: {str(e)}"
430
- raise ValueError(error_msg) from e
606
+ # Load regular .md files
607
+ for path in regular_md_files:
608
+ load_and_categorize(
609
+ path, skill_dir, repo_skills, knowledge_skills, agent_skills
610
+ )
431
611
 
612
+ total = len(repo_skills) + len(knowledge_skills) + len(agent_skills)
432
613
  logger.debug(
433
- f"Loaded {len(repo_skills) + len(knowledge_skills)} skills: "
434
- f"{[*repo_skills.keys(), *knowledge_skills.keys()]}"
614
+ f"Loaded {total} skills: "
615
+ f"repo={list(repo_skills.keys())}, "
616
+ f"knowledge={list(knowledge_skills.keys())}, "
617
+ f"agent={list(agent_skills.keys())}"
435
618
  )
436
- return repo_skills, knowledge_skills
619
+ return repo_skills, knowledge_skills, agent_skills
437
620
 
438
621
 
439
622
  # Default user skills directories (in order of priority)
@@ -464,10 +647,12 @@ def load_user_skills() -> list[Skill]:
464
647
 
465
648
  try:
466
649
  logger.debug(f"Loading user skills from {skills_dir}")
467
- repo_skills, knowledge_skills = load_skills_from_dir(skills_dir)
650
+ repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
651
+ skills_dir
652
+ )
468
653
 
469
- # Merge repo and knowledge skills
470
- for skills_dict in [repo_skills, knowledge_skills]:
654
+ # Merge all skill categories
655
+ for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
471
656
  for name, skill in skills_dict.items():
472
657
  if name not in seen_names:
473
658
  all_skills.append(skill)
@@ -494,6 +679,9 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
494
679
  directories are merged, with skills/ taking precedence for
495
680
  duplicate names.
496
681
 
682
+ Also loads third-party skill files (AGENTS.md, .cursorrules, etc.)
683
+ directly from the work directory.
684
+
497
685
  Args:
498
686
  work_dir: Path to the project/working directory.
499
687
 
@@ -505,7 +693,22 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
505
693
  work_dir = Path(work_dir)
506
694
 
507
695
  all_skills = []
508
- seen_names = set()
696
+ seen_names: set[str] = set()
697
+
698
+ # First, load third-party skill files directly from work directory
699
+ # This ensures they are loaded even if .openhands/skills doesn't exist
700
+ third_party_files = find_third_party_files(
701
+ work_dir, Skill.PATH_TO_THIRD_PARTY_SKILL_NAME
702
+ )
703
+ for path in third_party_files:
704
+ try:
705
+ skill = Skill.load(path)
706
+ if skill.name not in seen_names:
707
+ all_skills.append(skill)
708
+ seen_names.add(skill.name)
709
+ logger.debug(f"Loaded third-party skill: {skill.name} from {path}")
710
+ except (SkillError, OSError) as e:
711
+ logger.warning(f"Failed to load third-party skill from {path}: {e}")
509
712
 
510
713
  # Load project-specific skills from .openhands/skills and legacy microagents
511
714
  project_skills_dirs = [
@@ -522,10 +725,12 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
522
725
 
523
726
  try:
524
727
  logger.debug(f"Loading project skills from {project_skills_dir}")
525
- repo_skills, knowledge_skills = load_skills_from_dir(project_skills_dir)
728
+ repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
729
+ project_skills_dir
730
+ )
526
731
 
527
- # Merge repo and knowledge skills
528
- for skills_dict in [repo_skills, knowledge_skills]:
732
+ # Merge all skill categories (skip duplicates including third-party)
733
+ for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
529
734
  for name, skill in skills_dict.items():
530
735
  if name not in seen_names:
531
736
  all_skills.append(skill)
@@ -552,97 +757,6 @@ PUBLIC_SKILLS_REPO = "https://github.com/OpenHands/skills"
552
757
  PUBLIC_SKILLS_BRANCH = "main"
553
758
 
554
759
 
555
- def _get_skills_cache_dir() -> Path:
556
- """Get the local cache directory for public skills repository.
557
-
558
- Returns:
559
- Path to the skills cache directory (~/.openhands/cache/skills).
560
- """
561
- cache_dir = Path.home() / ".openhands" / "cache" / "skills"
562
- cache_dir.mkdir(parents=True, exist_ok=True)
563
- return cache_dir
564
-
565
-
566
- def _update_skills_repository(
567
- repo_url: str,
568
- branch: str,
569
- cache_dir: Path,
570
- ) -> Path | None:
571
- """Clone or update the local skills repository.
572
-
573
- Args:
574
- repo_url: URL of the skills repository.
575
- branch: Branch name to use.
576
- cache_dir: Directory where the repository should be cached.
577
-
578
- Returns:
579
- Path to the local repository if successful, None otherwise.
580
- """
581
- repo_path = cache_dir / "public-skills"
582
-
583
- try:
584
- if repo_path.exists() and (repo_path / ".git").exists():
585
- logger.debug(f"Updating skills repository at {repo_path}")
586
- try:
587
- subprocess.run(
588
- ["git", "fetch", "origin"],
589
- cwd=repo_path,
590
- check=True,
591
- capture_output=True,
592
- timeout=30,
593
- )
594
- subprocess.run(
595
- ["git", "reset", "--hard", f"origin/{branch}"],
596
- cwd=repo_path,
597
- check=True,
598
- capture_output=True,
599
- timeout=10,
600
- )
601
- logger.debug("Skills repository updated successfully")
602
- except subprocess.TimeoutExpired:
603
- logger.warning("Git pull timed out, using existing cached repository")
604
- except subprocess.CalledProcessError as e:
605
- logger.warning(
606
- f"Failed to update repository: {e.stderr.decode()}, "
607
- f"using existing cached version"
608
- )
609
- else:
610
- logger.info(f"Cloning public skills repository from {repo_url}")
611
- if repo_path.exists():
612
- shutil.rmtree(repo_path)
613
-
614
- subprocess.run(
615
- [
616
- "git",
617
- "clone",
618
- "--depth",
619
- "1",
620
- "--branch",
621
- branch,
622
- repo_url,
623
- str(repo_path),
624
- ],
625
- check=True,
626
- capture_output=True,
627
- timeout=60,
628
- )
629
- logger.debug(f"Skills repository cloned to {repo_path}")
630
-
631
- return repo_path
632
-
633
- except subprocess.TimeoutExpired:
634
- logger.warning(f"Git operation timed out for {repo_url}")
635
- return None
636
- except subprocess.CalledProcessError as e:
637
- logger.warning(
638
- f"Failed to clone/update repository {repo_url}: {e.stderr.decode()}"
639
- )
640
- return None
641
- except Exception as e:
642
- logger.warning(f"Error managing skills repository: {str(e)}")
643
- return None
644
-
645
-
646
760
  def load_public_skills(
647
761
  repo_url: str = PUBLIC_SKILLS_REPO,
648
762
  branch: str = PUBLIC_SKILLS_BRANCH,
@@ -678,8 +792,8 @@ def load_public_skills(
678
792
 
679
793
  try:
680
794
  # Get or update the local repository
681
- cache_dir = _get_skills_cache_dir()
682
- repo_path = _update_skills_repository(repo_url, branch, cache_dir)
795
+ cache_dir = get_skills_cache_dir()
796
+ repo_path = update_skills_repository(repo_url, branch, cache_dir)
683
797
 
684
798
  if repo_path is None:
685
799
  logger.warning("Failed to access public skills repository")
@@ -701,7 +815,7 @@ def load_public_skills(
701
815
  try:
702
816
  skill = Skill.load(
703
817
  path=skill_file,
704
- skill_dir=repo_path,
818
+ skill_base_dir=repo_path,
705
819
  )
706
820
  if skill is None:
707
821
  continue
@@ -718,3 +832,86 @@ def load_public_skills(
718
832
  f"Loaded {len(all_skills)} public skills: {[s.name for s in all_skills]}"
719
833
  )
720
834
  return all_skills
835
+
836
+
837
+ def to_prompt(skills: list[Skill], max_description_length: int = 200) -> str:
838
+ """Generate XML prompt block for available skills.
839
+
840
+ Creates an `<available_skills>` XML block suitable for inclusion
841
+ in system prompts, following the AgentSkills format from skills-ref.
842
+
843
+ Args:
844
+ skills: List of skills to include in the prompt
845
+ max_description_length: Maximum length for descriptions (default 200)
846
+
847
+ Returns:
848
+ XML string in AgentSkills format with name, description, and location
849
+
850
+ Example:
851
+ >>> skills = [Skill(name="pdf-tools", content="...",
852
+ ... description="Extract text from PDF files.",
853
+ ... source="/path/to/skill")]
854
+ >>> print(to_prompt(skills))
855
+ <available_skills>
856
+ <skill>
857
+ <name>pdf-tools</name>
858
+ <description>Extract text from PDF files.</description>
859
+ <location>/path/to/skill</location>
860
+ </skill>
861
+ </available_skills>
862
+ """
863
+ if not skills:
864
+ return "<available_skills>\n no available skills\n</available_skills>"
865
+
866
+ lines = ["<available_skills>"]
867
+ for skill in skills:
868
+ # Use description if available, otherwise use first line of content
869
+ description = skill.description
870
+ content_truncated = 0
871
+ if not description:
872
+ # Extract first non-empty, non-header line from content as fallback
873
+ # Track position to calculate truncated content after the description
874
+ chars_before_desc = 0
875
+ for line in skill.content.split("\n"):
876
+ stripped = line.strip()
877
+ # Skip markdown headers and empty lines
878
+ if not stripped or stripped.startswith("#"):
879
+ chars_before_desc += len(line) + 1 # +1 for newline
880
+ continue
881
+ description = stripped
882
+ # Calculate remaining content after this line as truncated
883
+ desc_end_pos = chars_before_desc + len(line)
884
+ content_truncated = max(0, len(skill.content) - desc_end_pos)
885
+ break
886
+ description = description or ""
887
+
888
+ # Calculate total truncated characters
889
+ total_truncated = content_truncated
890
+
891
+ # Truncate description if needed and add truncation indicator
892
+ if len(description) > max_description_length:
893
+ total_truncated += len(description) - max_description_length
894
+ description = description[:max_description_length]
895
+
896
+ if total_truncated > 0:
897
+ truncation_msg = f"... [{total_truncated} characters truncated"
898
+ if skill.source:
899
+ truncation_msg += f". View {skill.source} for complete information"
900
+ truncation_msg += "]"
901
+ description = description + truncation_msg
902
+
903
+ # Escape XML special characters using standard library
904
+ description = xml_escape(description.strip())
905
+ name = xml_escape(skill.name.strip())
906
+
907
+ # Build skill element following AgentSkills format from skills-ref
908
+ lines.append(" <skill>")
909
+ lines.append(f" <name>{name}</name>")
910
+ lines.append(f" <description>{description}</description>")
911
+ if skill.source:
912
+ source = xml_escape(skill.source.strip())
913
+ lines.append(f" <location>{source}</location>")
914
+ lines.append(" </skill>")
915
+
916
+ lines.append("</available_skills>")
917
+ return "\n".join(lines)