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.
- openhands/sdk/__init__.py +2 -0
- openhands/sdk/agent/agent.py +31 -1
- openhands/sdk/agent/base.py +111 -67
- 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/condenser/__init__.py +2 -0
- openhands/sdk/context/condenser/base.py +59 -8
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
- 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 +425 -228
- openhands/sdk/context/skills/types.py +4 -0
- openhands/sdk/context/skills/utils.py +442 -0
- openhands/sdk/context/view.py +2 -0
- openhands/sdk/conversation/impl/local_conversation.py +42 -14
- openhands/sdk/conversation/impl/remote_conversation.py +99 -55
- openhands/sdk/conversation/state.py +54 -18
- 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 +59 -76
- openhands/sdk/llm/options/chat_options.py +4 -1
- openhands/sdk/llm/router/base.py +12 -0
- openhands/sdk/llm/utils/telemetry.py +2 -2
- openhands/sdk/llm/utils/verified_models.py +1 -1
- openhands/sdk/mcp/tool.py +3 -1
- 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/schema.py +6 -3
- openhands/sdk/tool/tool.py +60 -9
- openhands/sdk/utils/models.py +198 -472
- openhands/sdk/workspace/base.py +22 -0
- openhands/sdk/workspace/local.py +16 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
- openhands/sdk/workspace/remote/base.py +16 -0
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
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
|
-
"
|
|
54
|
-
"None
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
276
|
+
return cls._load_legacy_openhands_skill(path, file_content, skill_base_dir)
|
|
209
277
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
#
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
for
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
#
|
|
412
|
-
for
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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 {
|
|
434
|
-
f"{
|
|
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(
|
|
650
|
+
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
|
|
651
|
+
skills_dir
|
|
652
|
+
)
|
|
468
653
|
|
|
469
|
-
# Merge
|
|
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(
|
|
728
|
+
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
|
|
729
|
+
project_skills_dir
|
|
730
|
+
)
|
|
526
731
|
|
|
527
|
-
# Merge
|
|
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 =
|
|
682
|
-
repo_path =
|
|
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
|
-
|
|
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)
|