openhands-sdk 1.7.4__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 (32) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +27 -0
  3. openhands/sdk/agent/base.py +88 -82
  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/prompts/templates/skill_knowledge_info.j2 +4 -0
  8. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  9. openhands/sdk/context/skills/__init__.py +12 -0
  10. openhands/sdk/context/skills/skill.py +275 -296
  11. openhands/sdk/context/skills/types.py +4 -0
  12. openhands/sdk/context/skills/utils.py +442 -0
  13. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  14. openhands/sdk/conversation/state.py +52 -20
  15. openhands/sdk/event/llm_convertible/action.py +20 -0
  16. openhands/sdk/git/utils.py +31 -6
  17. openhands/sdk/hooks/conversation_hooks.py +57 -10
  18. openhands/sdk/llm/llm.py +58 -74
  19. openhands/sdk/llm/router/base.py +12 -0
  20. openhands/sdk/llm/utils/telemetry.py +2 -2
  21. openhands/sdk/plugin/__init__.py +22 -0
  22. openhands/sdk/plugin/plugin.py +299 -0
  23. openhands/sdk/plugin/types.py +226 -0
  24. openhands/sdk/tool/__init__.py +7 -1
  25. openhands/sdk/tool/builtins/__init__.py +4 -0
  26. openhands/sdk/tool/tool.py +60 -9
  27. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  28. openhands/sdk/workspace/remote/base.py +16 -0
  29. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +1 -1
  30. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +32 -28
  31. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  32. {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,31 @@
1
1
  import io
2
2
  import re
3
- import shutil
4
- import subprocess
5
3
  from pathlib import Path
6
4
  from typing import Annotated, ClassVar, Union
5
+ from xml.sax.saxutils import escape as xml_escape
7
6
 
8
7
  import frontmatter
9
8
  from fastmcp.mcp_config import MCPConfig
10
9
  from pydantic import BaseModel, Field, field_validator, model_validator
11
10
 
12
- from openhands.sdk.context.skills.exceptions import SkillValidationError
11
+ from openhands.sdk.context.skills.exceptions import SkillError, SkillValidationError
13
12
  from openhands.sdk.context.skills.trigger import (
14
13
  KeywordTrigger,
15
14
  TaskTrigger,
16
15
  )
17
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
+ )
18
29
  from openhands.sdk.logger import get_logger
19
30
  from openhands.sdk.utils import maybe_truncate
20
31
 
@@ -25,12 +36,48 @@ logger = get_logger(__name__)
25
36
  # These files are always active, so we want to keep them reasonably sized
26
37
  THIRD_PARTY_SKILL_MAX_CHARS = 10_000
27
38
 
28
- # Regex pattern for valid AgentSkills names
29
- # - 1-64 characters
30
- # - Lowercase alphanumeric + hyphens only (a-z, 0-9, -)
31
- # - Must not start or end with hyphen
32
- # - Must not contain consecutive hyphens (--)
33
- SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
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
34
81
 
35
82
 
36
83
  # Union type for all trigger types
@@ -43,10 +90,16 @@ TriggerType = Annotated[
43
90
  class Skill(BaseModel):
44
91
  """A skill provides specialized knowledge or functionality.
45
92
 
46
- Skills use triggers to determine when they should be activated:
47
- - None: Always active, for repository-specific guidelines
48
- - KeywordTrigger: Activated when keywords appear in user messages
49
- - 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
50
103
 
51
104
  This model supports both OpenHands-specific fields and AgentSkills standard
52
105
  fields (https://agentskills.io/specification) for cross-platform compatibility.
@@ -57,11 +110,11 @@ class Skill(BaseModel):
57
110
  trigger: TriggerType | None = Field(
58
111
  default=None,
59
112
  description=(
60
- "Skills use triggers to determine when they should be activated. "
61
- "None implies skill is always active. "
62
- "Other implementations include KeywordTrigger (activated by a "
63
- "keyword in a Message) and TaskTrigger (activated by specific tasks "
64
- "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."
65
118
  ),
66
119
  )
67
120
  source: str | None = Field(
@@ -83,6 +136,16 @@ class Skill(BaseModel):
83
136
  default_factory=list,
84
137
  description="Input metadata for the skill (task skills only)",
85
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
+ )
86
149
 
87
150
  # AgentSkills standard fields (https://agentskills.io/specification)
88
151
  description: str | None = Field(
@@ -120,6 +183,23 @@ class Skill(BaseModel):
120
183
  "AgentSkills standard field (parsed from space-delimited string)."
121
184
  ),
122
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
123
203
 
124
204
  @field_validator("allowed_tools", mode="before")
125
205
  @classmethod
@@ -169,6 +249,7 @@ class Skill(BaseModel):
169
249
  cls,
170
250
  path: str | Path,
171
251
  skill_base_dir: Path | None = None,
252
+ strict: bool = True,
172
253
  ) -> "Skill":
173
254
  """Load a skill from a markdown file with frontmatter.
174
255
 
@@ -181,6 +262,8 @@ class Skill(BaseModel):
181
262
  Args:
182
263
  path: Path to the skill file.
183
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).
184
267
  """
185
268
  path = Path(path) if isinstance(path, str) else path
186
269
 
@@ -188,20 +271,24 @@ class Skill(BaseModel):
188
271
  file_content = f.read()
189
272
 
190
273
  if path.name.lower() == "skill.md":
191
- return cls._load_agentskills_skill(path, file_content)
274
+ return cls._load_agentskills_skill(path, file_content, strict=strict)
192
275
  else:
193
276
  return cls._load_legacy_openhands_skill(path, file_content, skill_base_dir)
194
277
 
195
278
  @classmethod
196
- def _load_agentskills_skill(cls, path: Path, file_content: str) -> "Skill":
279
+ def _load_agentskills_skill(
280
+ cls, path: Path, file_content: str, strict: bool = True
281
+ ) -> "Skill":
197
282
  """Load a skill from an AgentSkills-format SKILL.md file.
198
283
 
199
284
  Args:
200
285
  path: Path to the SKILL.md file.
201
286
  file_content: Content of the file.
287
+ strict: If True, enforce strict AgentSkills name validation.
202
288
  """
203
289
  # For SKILL.md files, use parent directory name as the skill name
204
290
  directory_name = path.parent.name
291
+ skill_root = path.parent
205
292
 
206
293
  file_io = io.StringIO(file_content)
207
294
  loaded = frontmatter.load(file_io)
@@ -211,14 +298,35 @@ class Skill(BaseModel):
211
298
  # Use name from frontmatter if provided, otherwise use directory name
212
299
  agent_name = str(metadata_dict.get("name", directory_name))
213
300
 
214
- # Validate skill name
215
- name_errors = _validate_skill_name(agent_name, directory_name)
216
- if name_errors:
217
- raise SkillValidationError(
218
- f"Invalid skill name '{agent_name}': {'; '.join(name_errors)}"
219
- )
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
+ )
220
308
 
221
- return cls._create_skill_from_metadata(agent_name, content, path, metadata_dict)
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
+ )
222
330
 
223
331
  @classmethod
224
332
  def _load_legacy_openhands_skill(
@@ -252,11 +360,25 @@ class Skill(BaseModel):
252
360
  # Use name from frontmatter if provided, otherwise use derived name
253
361
  agent_name = str(metadata_dict.get("name", skill_name))
254
362
 
255
- return cls._create_skill_from_metadata(agent_name, content, path, metadata_dict)
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
+ )
256
371
 
257
372
  @classmethod
258
373
  def _create_skill_from_metadata(
259
- cls, agent_name: str, content: str, path: Path, metadata_dict: dict
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,
260
382
  ) -> "Skill":
261
383
  """Create a Skill object from parsed metadata.
262
384
 
@@ -265,6 +387,9 @@ class Skill(BaseModel):
265
387
  content: The markdown content (without frontmatter).
266
388
  path: Path to the skill file.
267
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.
268
393
  """
269
394
  # Extract AgentSkills standard fields (Pydantic validators handle
270
395
  # transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
@@ -309,6 +434,9 @@ class Skill(BaseModel):
309
434
  source=str(path),
310
435
  trigger=TaskTrigger(triggers=keywords),
311
436
  inputs=inputs,
437
+ mcp_tools=mcp_tools,
438
+ resources=resources,
439
+ is_agentskills_format=is_agentskills_format,
312
440
  **agentskills_fields,
313
441
  )
314
442
 
@@ -318,19 +446,21 @@ class Skill(BaseModel):
318
446
  content=content,
319
447
  source=str(path),
320
448
  trigger=KeywordTrigger(keywords=keywords),
449
+ mcp_tools=mcp_tools,
450
+ resources=resources,
451
+ is_agentskills_format=is_agentskills_format,
321
452
  **agentskills_fields,
322
453
  )
323
454
  else:
324
455
  # No triggers, default to None (always active)
325
- mcp_tools = metadata_dict.get("mcp_tools")
326
- if mcp_tools is not None and not isinstance(mcp_tools, dict):
327
- raise SkillValidationError("mcp_tools must be a dictionary or None")
328
456
  return Skill(
329
457
  name=agent_name,
330
458
  content=content,
331
459
  source=str(path),
332
460
  trigger=None,
333
461
  mcp_tools=mcp_tools,
462
+ resources=resources,
463
+ is_agentskills_format=is_agentskills_format,
334
464
  **agentskills_fields,
335
465
  )
336
466
 
@@ -431,162 +561,6 @@ class Skill(BaseModel):
431
561
  return len(variables) > 0
432
562
 
433
563
 
434
- def _find_skill_md(skill_dir: Path) -> Path | None:
435
- """Find SKILL.md file in a directory (case-insensitive).
436
-
437
- Args:
438
- skill_dir: Path to the skill directory to search.
439
-
440
- Returns:
441
- Path to SKILL.md if found, None otherwise.
442
- """
443
- if not skill_dir.is_dir():
444
- return None
445
- for item in skill_dir.iterdir():
446
- if item.is_file() and item.name.lower() == "skill.md":
447
- return item
448
- return None
449
-
450
-
451
- def _validate_skill_name(name: str, directory_name: str | None = None) -> list[str]:
452
- """Validate skill name according to AgentSkills spec.
453
-
454
- Args:
455
- name: The skill name to validate.
456
- directory_name: Optional directory name to check for match.
457
-
458
- Returns:
459
- List of validation error messages (empty if valid).
460
- """
461
- errors = []
462
-
463
- if not name:
464
- errors.append("Name cannot be empty")
465
- return errors
466
-
467
- if len(name) > 64:
468
- errors.append(f"Name exceeds 64 characters: {len(name)}")
469
-
470
- if not SKILL_NAME_PATTERN.match(name):
471
- errors.append(
472
- "Name must be lowercase alphanumeric with single hyphens "
473
- "(e.g., 'my-skill', 'pdf-tools')"
474
- )
475
-
476
- if directory_name and name != directory_name:
477
- errors.append(f"Name '{name}' does not match directory '{directory_name}'")
478
-
479
- return errors
480
-
481
-
482
- def _find_third_party_files(repo_root: Path) -> list[Path]:
483
- """Find third-party skill files in the repository root.
484
-
485
- Searches for files like .cursorrules, AGENTS.md, CLAUDE.md, etc.
486
- with case-insensitive matching.
487
-
488
- Args:
489
- repo_root: Path to the repository root directory.
490
-
491
- Returns:
492
- List of paths to third-party skill files found.
493
- """
494
- if not repo_root.exists():
495
- return []
496
-
497
- # Build a set of target filenames (lowercase) for case-insensitive matching
498
- target_names = {name.lower() for name in Skill.PATH_TO_THIRD_PARTY_SKILL_NAME}
499
-
500
- files: list[Path] = []
501
- seen_names: set[str] = set()
502
- for item in repo_root.iterdir():
503
- if item.is_file() and item.name.lower() in target_names:
504
- # Avoid duplicates (e.g., AGENTS.md and agents.md in same dir)
505
- name_lower = item.name.lower()
506
- if name_lower in seen_names:
507
- logger.warning(
508
- f"Duplicate third-party skill file ignored: {item} "
509
- f"(already found a file with name '{name_lower}')"
510
- )
511
- else:
512
- files.append(item)
513
- seen_names.add(name_lower)
514
- return files
515
-
516
-
517
- def _find_skill_md_directories(skill_dir: Path) -> list[Path]:
518
- """Find AgentSkills-style directories containing SKILL.md files.
519
-
520
- Args:
521
- skill_dir: Path to the skills directory.
522
-
523
- Returns:
524
- List of paths to SKILL.md files.
525
- """
526
- results: list[Path] = []
527
- if not skill_dir.exists():
528
- return results
529
- for subdir in skill_dir.iterdir():
530
- if subdir.is_dir():
531
- skill_md = _find_skill_md(subdir)
532
- if skill_md:
533
- results.append(skill_md)
534
- return results
535
-
536
-
537
- def _find_regular_md_files(skill_dir: Path, exclude_dirs: set[Path]) -> list[Path]:
538
- """Find regular .md skill files, excluding SKILL.md and files in excluded dirs.
539
-
540
- Args:
541
- skill_dir: Path to the skills directory.
542
- exclude_dirs: Set of directories to exclude (e.g., SKILL.md directories).
543
-
544
- Returns:
545
- List of paths to regular .md skill files.
546
- """
547
- files: list[Path] = []
548
- if not skill_dir.exists():
549
- return files
550
- for f in skill_dir.rglob("*.md"):
551
- is_readme = f.name == "README.md"
552
- is_skill_md = f.name.lower() == "skill.md"
553
- is_in_excluded_dir = any(f.is_relative_to(d) for d in exclude_dirs)
554
- if not is_readme and not is_skill_md and not is_in_excluded_dir:
555
- files.append(f)
556
- return files
557
-
558
-
559
- def _load_and_categorize(
560
- path: Path,
561
- skill_base_dir: Path,
562
- repo_skills: dict[str, Skill],
563
- knowledge_skills: dict[str, Skill],
564
- agent_skills: dict[str, Skill],
565
- ) -> None:
566
- """Load a skill and categorize it.
567
-
568
- Categorizes into repo_skills, knowledge_skills, or agent_skills.
569
-
570
- Args:
571
- path: Path to the skill file.
572
- skill_base_dir: Base directory for skills (used to derive relative names).
573
- repo_skills: Dictionary for skills with trigger=None (permanent context).
574
- knowledge_skills: Dictionary for skills with triggers (progressive).
575
- agent_skills: Dictionary for AgentSkills standard SKILL.md files.
576
- """
577
- skill = Skill.load(path, skill_base_dir)
578
-
579
- # AgentSkills (SKILL.md directories) are a separate category from OpenHands skills.
580
- # They follow the AgentSkills standard and should be handled differently.
581
- is_skill_md = path.name.lower() == "skill.md"
582
- if is_skill_md:
583
- agent_skills[skill.name] = skill
584
- elif skill.trigger is None:
585
- repo_skills[skill.name] = skill
586
- else:
587
- knowledge_skills[skill.name] = skill
588
-
589
-
590
564
  def load_skills_from_dir(
591
565
  skill_dir: str | Path,
592
566
  ) -> tuple[dict[str, Skill], dict[str, Skill], dict[str, Skill]]:
@@ -615,28 +589,23 @@ def load_skills_from_dir(
615
589
  agent_skills: dict[str, Skill] = {}
616
590
  logger.debug(f"Loading agents from {skill_dir}")
617
591
 
618
- # Discover all skill files
619
- repo_root = skill_dir.parent.parent
620
- third_party_files = _find_third_party_files(repo_root)
621
- skill_md_files = _find_skill_md_directories(skill_dir)
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)
622
597
  skill_md_dirs = {skill_md.parent for skill_md in skill_md_files}
623
- regular_md_files = _find_regular_md_files(skill_dir, skill_md_dirs)
624
-
625
- # Load third-party files
626
- for path in third_party_files:
627
- _load_and_categorize(
628
- path, skill_dir, repo_skills, knowledge_skills, agent_skills
629
- )
598
+ regular_md_files = find_regular_md_files(skill_dir, skill_md_dirs)
630
599
 
631
600
  # Load SKILL.md files (auto-detected and validated in Skill.load)
632
601
  for skill_md_path in skill_md_files:
633
- _load_and_categorize(
602
+ load_and_categorize(
634
603
  skill_md_path, skill_dir, repo_skills, knowledge_skills, agent_skills
635
604
  )
636
605
 
637
606
  # Load regular .md files
638
607
  for path in regular_md_files:
639
- _load_and_categorize(
608
+ load_and_categorize(
640
609
  path, skill_dir, repo_skills, knowledge_skills, agent_skills
641
610
  )
642
611
 
@@ -710,6 +679,9 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
710
679
  directories are merged, with skills/ taking precedence for
711
680
  duplicate names.
712
681
 
682
+ Also loads third-party skill files (AGENTS.md, .cursorrules, etc.)
683
+ directly from the work directory.
684
+
713
685
  Args:
714
686
  work_dir: Path to the project/working directory.
715
687
 
@@ -721,7 +693,22 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
721
693
  work_dir = Path(work_dir)
722
694
 
723
695
  all_skills = []
724
- 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}")
725
712
 
726
713
  # Load project-specific skills from .openhands/skills and legacy microagents
727
714
  project_skills_dirs = [
@@ -742,7 +729,7 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
742
729
  project_skills_dir
743
730
  )
744
731
 
745
- # Merge all skill categories
732
+ # Merge all skill categories (skip duplicates including third-party)
746
733
  for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
747
734
  for name, skill in skills_dict.items():
748
735
  if name not in seen_names:
@@ -770,97 +757,6 @@ PUBLIC_SKILLS_REPO = "https://github.com/OpenHands/skills"
770
757
  PUBLIC_SKILLS_BRANCH = "main"
771
758
 
772
759
 
773
- def _get_skills_cache_dir() -> Path:
774
- """Get the local cache directory for public skills repository.
775
-
776
- Returns:
777
- Path to the skills cache directory (~/.openhands/cache/skills).
778
- """
779
- cache_dir = Path.home() / ".openhands" / "cache" / "skills"
780
- cache_dir.mkdir(parents=True, exist_ok=True)
781
- return cache_dir
782
-
783
-
784
- def _update_skills_repository(
785
- repo_url: str,
786
- branch: str,
787
- cache_dir: Path,
788
- ) -> Path | None:
789
- """Clone or update the local skills repository.
790
-
791
- Args:
792
- repo_url: URL of the skills repository.
793
- branch: Branch name to use.
794
- cache_dir: Directory where the repository should be cached.
795
-
796
- Returns:
797
- Path to the local repository if successful, None otherwise.
798
- """
799
- repo_path = cache_dir / "public-skills"
800
-
801
- try:
802
- if repo_path.exists() and (repo_path / ".git").exists():
803
- logger.debug(f"Updating skills repository at {repo_path}")
804
- try:
805
- subprocess.run(
806
- ["git", "fetch", "origin"],
807
- cwd=repo_path,
808
- check=True,
809
- capture_output=True,
810
- timeout=30,
811
- )
812
- subprocess.run(
813
- ["git", "reset", "--hard", f"origin/{branch}"],
814
- cwd=repo_path,
815
- check=True,
816
- capture_output=True,
817
- timeout=10,
818
- )
819
- logger.debug("Skills repository updated successfully")
820
- except subprocess.TimeoutExpired:
821
- logger.warning("Git pull timed out, using existing cached repository")
822
- except subprocess.CalledProcessError as e:
823
- logger.warning(
824
- f"Failed to update repository: {e.stderr.decode()}, "
825
- f"using existing cached version"
826
- )
827
- else:
828
- logger.info(f"Cloning public skills repository from {repo_url}")
829
- if repo_path.exists():
830
- shutil.rmtree(repo_path)
831
-
832
- subprocess.run(
833
- [
834
- "git",
835
- "clone",
836
- "--depth",
837
- "1",
838
- "--branch",
839
- branch,
840
- repo_url,
841
- str(repo_path),
842
- ],
843
- check=True,
844
- capture_output=True,
845
- timeout=60,
846
- )
847
- logger.debug(f"Skills repository cloned to {repo_path}")
848
-
849
- return repo_path
850
-
851
- except subprocess.TimeoutExpired:
852
- logger.warning(f"Git operation timed out for {repo_url}")
853
- return None
854
- except subprocess.CalledProcessError as e:
855
- logger.warning(
856
- f"Failed to clone/update repository {repo_url}: {e.stderr.decode()}"
857
- )
858
- return None
859
- except Exception as e:
860
- logger.warning(f"Error managing skills repository: {str(e)}")
861
- return None
862
-
863
-
864
760
  def load_public_skills(
865
761
  repo_url: str = PUBLIC_SKILLS_REPO,
866
762
  branch: str = PUBLIC_SKILLS_BRANCH,
@@ -896,8 +792,8 @@ def load_public_skills(
896
792
 
897
793
  try:
898
794
  # Get or update the local repository
899
- cache_dir = _get_skills_cache_dir()
900
- 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)
901
797
 
902
798
  if repo_path is None:
903
799
  logger.warning("Failed to access public skills repository")
@@ -936,3 +832,86 @@ def load_public_skills(
936
832
  f"Loaded {len(all_skills)} public skills: {[s.name for s in all_skills]}"
937
833
  )
938
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)