openhands-sdk 1.7.4__py3-none-any.whl → 1.8.1__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.
- openhands/sdk/__init__.py +2 -0
- openhands/sdk/agent/agent.py +27 -0
- openhands/sdk/agent/base.py +88 -82
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
- openhands/sdk/agent/utils.py +3 -0
- openhands/sdk/context/agent_context.py +45 -3
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
- openhands/sdk/context/skills/__init__.py +12 -0
- openhands/sdk/context/skills/skill.py +275 -296
- openhands/sdk/context/skills/types.py +4 -0
- openhands/sdk/context/skills/utils.py +442 -0
- openhands/sdk/conversation/impl/local_conversation.py +42 -14
- openhands/sdk/conversation/state.py +52 -20
- openhands/sdk/event/llm_convertible/action.py +20 -0
- openhands/sdk/git/utils.py +31 -6
- openhands/sdk/hooks/conversation_hooks.py +57 -10
- openhands/sdk/llm/llm.py +58 -74
- openhands/sdk/llm/router/base.py +12 -0
- openhands/sdk/llm/utils/telemetry.py +2 -2
- openhands/sdk/plugin/__init__.py +22 -0
- openhands/sdk/plugin/plugin.py +299 -0
- openhands/sdk/plugin/types.py +226 -0
- openhands/sdk/tool/__init__.py +7 -1
- openhands/sdk/tool/builtins/__init__.py +4 -0
- openhands/sdk/tool/tool.py +60 -9
- openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
- openhands/sdk/workspace/remote/base.py +16 -0
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/METADATA +1 -1
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/RECORD +32 -28
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.4.dist-info → openhands_sdk-1.8.1.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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
-
|
|
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
|
-
"
|
|
61
|
-
"None
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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(
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
900
|
-
repo_path =
|
|
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)
|