openhands-sdk 1.7.2__py3-none-any.whl → 1.7.4__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/agent/agent.py +26 -10
- openhands/sdk/agent/base.py +53 -15
- openhands/sdk/context/condenser/__init__.py +2 -0
- openhands/sdk/context/condenser/base.py +59 -8
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +42 -4
- openhands/sdk/context/skills/skill.py +336 -118
- openhands/sdk/context/view.py +2 -0
- openhands/sdk/conversation/impl/remote_conversation.py +110 -29
- openhands/sdk/conversation/state.py +9 -5
- openhands/sdk/llm/llm.py +1 -2
- openhands/sdk/llm/options/chat_options.py +4 -1
- openhands/sdk/llm/utils/model_features.py +1 -0
- openhands/sdk/llm/utils/verified_models.py +1 -1
- openhands/sdk/mcp/tool.py +3 -1
- openhands/sdk/tool/registry.py +23 -0
- openhands/sdk/tool/schema.py +6 -3
- openhands/sdk/utils/models.py +198 -472
- openhands/sdk/workspace/base.py +22 -0
- openhands/sdk/workspace/local.py +16 -0
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/METADATA +2 -2
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/RECORD +23 -23
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.2.dist-info → openhands_sdk-1.7.4.dist-info}/top_level.txt +0 -0
|
@@ -2,7 +2,6 @@ import io
|
|
|
2
2
|
import re
|
|
3
3
|
import shutil
|
|
4
4
|
import subprocess
|
|
5
|
-
from itertools import chain
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import Annotated, ClassVar, Union
|
|
8
7
|
|
|
@@ -26,6 +25,14 @@ logger = get_logger(__name__)
|
|
|
26
25
|
# These files are always active, so we want to keep them reasonably sized
|
|
27
26
|
THIRD_PARTY_SKILL_MAX_CHARS = 10_000
|
|
28
27
|
|
|
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]+)*$")
|
|
34
|
+
|
|
35
|
+
|
|
29
36
|
# Union type for all trigger types
|
|
30
37
|
TriggerType = Annotated[
|
|
31
38
|
KeywordTrigger | TaskTrigger,
|
|
@@ -136,6 +143,19 @@ class Skill(BaseModel):
|
|
|
136
143
|
return {str(k): str(val) for k, val in v.items()}
|
|
137
144
|
raise SkillValidationError("metadata must be a dictionary")
|
|
138
145
|
|
|
146
|
+
@field_validator("mcp_tools")
|
|
147
|
+
@classmethod
|
|
148
|
+
def _validate_mcp_tools(cls, v: dict | None, _info):
|
|
149
|
+
"""Validate mcp_tools conforms to MCPConfig schema."""
|
|
150
|
+
if v is None:
|
|
151
|
+
return v
|
|
152
|
+
if isinstance(v, dict):
|
|
153
|
+
try:
|
|
154
|
+
MCPConfig.model_validate(v)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise SkillValidationError(f"Invalid MCPConfig dictionary: {e}") from e
|
|
157
|
+
return v
|
|
158
|
+
|
|
139
159
|
PATH_TO_THIRD_PARTY_SKILL_NAME: ClassVar[dict[str, str]] = {
|
|
140
160
|
".cursorrules": "cursorrules",
|
|
141
161
|
"agents.md": "agents",
|
|
@@ -144,89 +164,108 @@ class Skill(BaseModel):
|
|
|
144
164
|
"gemini.md": "gemini",
|
|
145
165
|
}
|
|
146
166
|
|
|
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
167
|
@classmethod
|
|
185
168
|
def load(
|
|
186
169
|
cls,
|
|
187
170
|
path: str | Path,
|
|
188
|
-
|
|
189
|
-
file_content: str | None = None,
|
|
171
|
+
skill_base_dir: Path | None = None,
|
|
190
172
|
) -> "Skill":
|
|
191
173
|
"""Load a skill from a markdown file with frontmatter.
|
|
192
174
|
|
|
193
|
-
The agent's name is derived from its path relative to
|
|
175
|
+
The agent's name is derived from its path relative to skill_base_dir,
|
|
176
|
+
or from the directory name for AgentSkills-style SKILL.md files.
|
|
194
177
|
|
|
195
178
|
Supports both OpenHands-specific frontmatter fields and AgentSkills
|
|
196
179
|
standard fields (https://agentskills.io/specification).
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
path: Path to the skill file.
|
|
183
|
+
skill_base_dir: Base directory for skills (used to derive relative names).
|
|
197
184
|
"""
|
|
198
185
|
path = Path(path) if isinstance(path, str) else path
|
|
199
186
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
path.name.lower()
|
|
206
|
-
) or str(path.relative_to(skill_dir).with_suffix(""))
|
|
187
|
+
with open(path) as f:
|
|
188
|
+
file_content = f.read()
|
|
189
|
+
|
|
190
|
+
if path.name.lower() == "skill.md":
|
|
191
|
+
return cls._load_agentskills_skill(path, file_content)
|
|
207
192
|
else:
|
|
208
|
-
|
|
193
|
+
return cls._load_legacy_openhands_skill(path, file_content, skill_base_dir)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def _load_agentskills_skill(cls, path: Path, file_content: str) -> "Skill":
|
|
197
|
+
"""Load a skill from an AgentSkills-format SKILL.md file.
|
|
209
198
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
199
|
+
Args:
|
|
200
|
+
path: Path to the SKILL.md file.
|
|
201
|
+
file_content: Content of the file.
|
|
202
|
+
"""
|
|
203
|
+
# For SKILL.md files, use parent directory name as the skill name
|
|
204
|
+
directory_name = path.parent.name
|
|
214
205
|
|
|
206
|
+
file_io = io.StringIO(file_content)
|
|
207
|
+
loaded = frontmatter.load(file_io)
|
|
208
|
+
content = loaded.content
|
|
209
|
+
metadata_dict = loaded.metadata or {}
|
|
210
|
+
|
|
211
|
+
# Use name from frontmatter if provided, otherwise use directory name
|
|
212
|
+
agent_name = str(metadata_dict.get("name", directory_name))
|
|
213
|
+
|
|
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
|
+
)
|
|
220
|
+
|
|
221
|
+
return cls._create_skill_from_metadata(agent_name, content, path, metadata_dict)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def _load_legacy_openhands_skill(
|
|
225
|
+
cls, path: Path, file_content: str, skill_base_dir: Path | None
|
|
226
|
+
) -> "Skill":
|
|
227
|
+
"""Load a skill from a legacy OpenHands-format file.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
path: Path to the skill file.
|
|
231
|
+
file_content: Content of the file.
|
|
232
|
+
skill_base_dir: Base directory for skills (used to derive relative names).
|
|
233
|
+
"""
|
|
215
234
|
# Handle third-party agent instruction files
|
|
216
235
|
third_party_agent = cls._handle_third_party(path, file_content)
|
|
217
236
|
if third_party_agent is not None:
|
|
218
237
|
return third_party_agent
|
|
219
238
|
|
|
239
|
+
# Calculate derived name from path
|
|
240
|
+
if skill_base_dir is not None:
|
|
241
|
+
skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(
|
|
242
|
+
path.name.lower()
|
|
243
|
+
) or str(path.relative_to(skill_base_dir).with_suffix(""))
|
|
244
|
+
else:
|
|
245
|
+
skill_name = path.stem
|
|
246
|
+
|
|
220
247
|
file_io = io.StringIO(file_content)
|
|
221
248
|
loaded = frontmatter.load(file_io)
|
|
222
249
|
content = loaded.content
|
|
223
|
-
|
|
224
|
-
# Handle case where there's no frontmatter or empty frontmatter
|
|
225
250
|
metadata_dict = loaded.metadata or {}
|
|
226
251
|
|
|
227
252
|
# Use name from frontmatter if provided, otherwise use derived name
|
|
228
253
|
agent_name = str(metadata_dict.get("name", skill_name))
|
|
229
254
|
|
|
255
|
+
return cls._create_skill_from_metadata(agent_name, content, path, metadata_dict)
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def _create_skill_from_metadata(
|
|
259
|
+
cls, agent_name: str, content: str, path: Path, metadata_dict: dict
|
|
260
|
+
) -> "Skill":
|
|
261
|
+
"""Create a Skill object from parsed metadata.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
agent_name: The name of the skill.
|
|
265
|
+
content: The markdown content (without frontmatter).
|
|
266
|
+
path: Path to the skill file.
|
|
267
|
+
metadata_dict: Parsed frontmatter metadata.
|
|
268
|
+
"""
|
|
230
269
|
# Extract AgentSkills standard fields (Pydantic validators handle
|
|
231
270
|
# transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
|
|
232
271
|
allowed_tools_value = metadata_dict.get(
|
|
@@ -284,7 +323,7 @@ class Skill(BaseModel):
|
|
|
284
323
|
else:
|
|
285
324
|
# No triggers, default to None (always active)
|
|
286
325
|
mcp_tools = metadata_dict.get("mcp_tools")
|
|
287
|
-
if not isinstance(mcp_tools, dict
|
|
326
|
+
if mcp_tools is not None and not isinstance(mcp_tools, dict):
|
|
288
327
|
raise SkillValidationError("mcp_tools must be a dictionary or None")
|
|
289
328
|
return Skill(
|
|
290
329
|
name=agent_name,
|
|
@@ -295,18 +334,42 @@ class Skill(BaseModel):
|
|
|
295
334
|
**agentskills_fields,
|
|
296
335
|
)
|
|
297
336
|
|
|
298
|
-
# Field-level validation for mcp_tools
|
|
299
|
-
@field_validator("mcp_tools")
|
|
300
337
|
@classmethod
|
|
301
|
-
def
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
338
|
+
def _handle_third_party(cls, path: Path, file_content: str) -> Union["Skill", None]:
|
|
339
|
+
"""Handle third-party skill files (e.g., .cursorrules, AGENTS.md).
|
|
340
|
+
|
|
341
|
+
Creates a Skill with None trigger (always active) if the file type
|
|
342
|
+
is recognized. Truncates content if it exceeds the limit.
|
|
343
|
+
"""
|
|
344
|
+
skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(path.name.lower())
|
|
345
|
+
|
|
346
|
+
if skill_name is not None:
|
|
347
|
+
truncated_content = maybe_truncate(
|
|
348
|
+
file_content,
|
|
349
|
+
truncate_after=THIRD_PARTY_SKILL_MAX_CHARS,
|
|
350
|
+
truncate_notice=(
|
|
351
|
+
f"\n\n<TRUNCATED><NOTE>The file {path} exceeded the "
|
|
352
|
+
f"maximum length ({THIRD_PARTY_SKILL_MAX_CHARS} "
|
|
353
|
+
f"characters) and has been truncated. Only the "
|
|
354
|
+
f"beginning and end are shown. You can read the full "
|
|
355
|
+
f"file if needed.</NOTE>\n\n"
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if len(file_content) > THIRD_PARTY_SKILL_MAX_CHARS:
|
|
360
|
+
logger.warning(
|
|
361
|
+
f"Third-party skill file {path} ({len(file_content)} chars) "
|
|
362
|
+
f"exceeded limit ({THIRD_PARTY_SKILL_MAX_CHARS} chars), truncating"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return Skill(
|
|
366
|
+
name=skill_name,
|
|
367
|
+
content=truncated_content,
|
|
368
|
+
source=str(path),
|
|
369
|
+
trigger=None,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return None
|
|
310
373
|
|
|
311
374
|
@model_validator(mode="after")
|
|
312
375
|
def _append_missing_variables_prompt(self):
|
|
@@ -368,72 +431,223 @@ class Skill(BaseModel):
|
|
|
368
431
|
return len(variables) > 0
|
|
369
432
|
|
|
370
433
|
|
|
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
|
+
|
|
371
590
|
def load_skills_from_dir(
|
|
372
591
|
skill_dir: str | Path,
|
|
373
|
-
) -> tuple[dict[str, Skill], dict[str, Skill]]:
|
|
592
|
+
) -> tuple[dict[str, Skill], dict[str, Skill], dict[str, Skill]]:
|
|
374
593
|
"""Load all skills from the given directory.
|
|
375
594
|
|
|
595
|
+
Supports both formats:
|
|
596
|
+
- OpenHands format: skills/*.md files
|
|
597
|
+
- AgentSkills format: skills/skill-name/SKILL.md directories
|
|
598
|
+
|
|
376
599
|
Note, legacy repo instructions will not be loaded here.
|
|
377
600
|
|
|
378
601
|
Args:
|
|
379
602
|
skill_dir: Path to the skills directory (e.g. .openhands/skills)
|
|
380
603
|
|
|
381
604
|
Returns:
|
|
382
|
-
Tuple of (repo_skills, knowledge_skills) dictionaries.
|
|
383
|
-
repo_skills
|
|
384
|
-
or TaskTrigger
|
|
605
|
+
Tuple of (repo_skills, knowledge_skills, agent_skills) dictionaries.
|
|
606
|
+
- repo_skills: Skills with trigger=None (permanent context)
|
|
607
|
+
- knowledge_skills: Skills with KeywordTrigger or TaskTrigger (progressive)
|
|
608
|
+
- agent_skills: AgentSkills standard SKILL.md files (separate category)
|
|
385
609
|
"""
|
|
386
610
|
if isinstance(skill_dir, str):
|
|
387
611
|
skill_dir = Path(skill_dir)
|
|
388
612
|
|
|
389
|
-
repo_skills = {}
|
|
390
|
-
knowledge_skills = {}
|
|
391
|
-
|
|
392
|
-
# Load all agents from skills directory
|
|
613
|
+
repo_skills: dict[str, Skill] = {}
|
|
614
|
+
knowledge_skills: dict[str, Skill] = {}
|
|
615
|
+
agent_skills: dict[str, Skill] = {}
|
|
393
616
|
logger.debug(f"Loading agents from {skill_dir}")
|
|
394
617
|
|
|
395
|
-
#
|
|
396
|
-
special_files = []
|
|
618
|
+
# Discover all skill files
|
|
397
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)
|
|
622
|
+
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
|
+
)
|
|
398
630
|
|
|
399
|
-
#
|
|
400
|
-
for
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
break # Only add the first one found to avoid duplicates
|
|
405
|
-
|
|
406
|
-
# Collect .md files from skills directory if it exists
|
|
407
|
-
md_files = []
|
|
408
|
-
if skill_dir.exists():
|
|
409
|
-
md_files = [f for f in skill_dir.rglob("*.md") if f.name != "README.md"]
|
|
631
|
+
# Load SKILL.md files (auto-detected and validated in Skill.load)
|
|
632
|
+
for skill_md_path in skill_md_files:
|
|
633
|
+
_load_and_categorize(
|
|
634
|
+
skill_md_path, skill_dir, repo_skills, knowledge_skills, agent_skills
|
|
635
|
+
)
|
|
410
636
|
|
|
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
|
|
637
|
+
# Load regular .md files
|
|
638
|
+
for path in regular_md_files:
|
|
639
|
+
_load_and_categorize(
|
|
640
|
+
path, skill_dir, repo_skills, knowledge_skills, agent_skills
|
|
641
|
+
)
|
|
431
642
|
|
|
643
|
+
total = len(repo_skills) + len(knowledge_skills) + len(agent_skills)
|
|
432
644
|
logger.debug(
|
|
433
|
-
f"Loaded {
|
|
434
|
-
f"{
|
|
645
|
+
f"Loaded {total} skills: "
|
|
646
|
+
f"repo={list(repo_skills.keys())}, "
|
|
647
|
+
f"knowledge={list(knowledge_skills.keys())}, "
|
|
648
|
+
f"agent={list(agent_skills.keys())}"
|
|
435
649
|
)
|
|
436
|
-
return repo_skills, knowledge_skills
|
|
650
|
+
return repo_skills, knowledge_skills, agent_skills
|
|
437
651
|
|
|
438
652
|
|
|
439
653
|
# Default user skills directories (in order of priority)
|
|
@@ -464,10 +678,12 @@ def load_user_skills() -> list[Skill]:
|
|
|
464
678
|
|
|
465
679
|
try:
|
|
466
680
|
logger.debug(f"Loading user skills from {skills_dir}")
|
|
467
|
-
repo_skills, knowledge_skills = load_skills_from_dir(
|
|
681
|
+
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
|
|
682
|
+
skills_dir
|
|
683
|
+
)
|
|
468
684
|
|
|
469
|
-
# Merge
|
|
470
|
-
for skills_dict in [repo_skills, knowledge_skills]:
|
|
685
|
+
# Merge all skill categories
|
|
686
|
+
for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
|
|
471
687
|
for name, skill in skills_dict.items():
|
|
472
688
|
if name not in seen_names:
|
|
473
689
|
all_skills.append(skill)
|
|
@@ -522,10 +738,12 @@ def load_project_skills(work_dir: str | Path) -> list[Skill]:
|
|
|
522
738
|
|
|
523
739
|
try:
|
|
524
740
|
logger.debug(f"Loading project skills from {project_skills_dir}")
|
|
525
|
-
repo_skills, knowledge_skills = load_skills_from_dir(
|
|
741
|
+
repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
|
|
742
|
+
project_skills_dir
|
|
743
|
+
)
|
|
526
744
|
|
|
527
|
-
# Merge
|
|
528
|
-
for skills_dict in [repo_skills, knowledge_skills]:
|
|
745
|
+
# Merge all skill categories
|
|
746
|
+
for skills_dict in [repo_skills, knowledge_skills, agent_skills]:
|
|
529
747
|
for name, skill in skills_dict.items():
|
|
530
748
|
if name not in seen_names:
|
|
531
749
|
all_skills.append(skill)
|
|
@@ -701,7 +919,7 @@ def load_public_skills(
|
|
|
701
919
|
try:
|
|
702
920
|
skill = Skill.load(
|
|
703
921
|
path=skill_file,
|
|
704
|
-
|
|
922
|
+
skill_base_dir=repo_path,
|
|
705
923
|
)
|
|
706
924
|
if skill is None:
|
|
707
925
|
continue
|
openhands/sdk/context/view.py
CHANGED
|
@@ -489,9 +489,11 @@ class View(BaseModel):
|
|
|
489
489
|
# Check for an unhandled condensation request -- these are events closer to the
|
|
490
490
|
# end of the list than any condensation action.
|
|
491
491
|
unhandled_condensation_request = False
|
|
492
|
+
|
|
492
493
|
for event in reversed(events):
|
|
493
494
|
if isinstance(event, Condensation):
|
|
494
495
|
break
|
|
496
|
+
|
|
495
497
|
if isinstance(event, CondensationRequest):
|
|
496
498
|
unhandled_condensation_request = True
|
|
497
499
|
break
|