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.
@@ -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
- skill_dir: Path | None = None,
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 the skill_dir.
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
- # Calculate derived name from relative path if skill_dir is provided
201
- skill_name = None
202
- if skill_dir is not None:
203
- # Special handling for files which are not in skill_dir
204
- skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(
205
- path.name.lower()
206
- ) or str(path.relative_to(skill_dir).with_suffix(""))
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
- skill_name = path.stem
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
- # Only load directly from path if file_content is not provided
211
- if file_content is None:
212
- with open(path) as f:
213
- file_content = f.read()
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 | None):
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 _validate_mcp_tools(cls, v: dict | None, _info):
302
- if v is None:
303
- return v
304
- if isinstance(v, dict):
305
- try:
306
- MCPConfig.model_validate(v)
307
- except Exception as e:
308
- raise SkillValidationError(f"Invalid MCPConfig dictionary: {e}") from e
309
- return v
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 have trigger=None, knowledge_skills have KeywordTrigger
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
- # Always check for .cursorrules and AGENTS.md files in repo root
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
- # Check for third party rules: .cursorrules, AGENTS.md, etc
400
- for filename in Skill.PATH_TO_THIRD_PARTY_SKILL_NAME.keys():
401
- for variant in [filename, filename.lower(), filename.upper()]:
402
- if (repo_root / variant).exists():
403
- special_files.append(repo_root / variant)
404
- break # Only add the first one found to avoid duplicates
405
-
406
- # Collect .md files from skills directory if it exists
407
- md_files = []
408
- if skill_dir.exists():
409
- md_files = [f for f in skill_dir.rglob("*.md") if f.name != "README.md"]
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
- # Process all files in one loop
412
- for file in chain(special_files, md_files):
413
- try:
414
- skill = Skill.load(
415
- file,
416
- skill_dir,
417
- )
418
- if skill.trigger is None:
419
- repo_skills[skill.name] = skill
420
- else:
421
- # KeywordTrigger and TaskTrigger skills
422
- knowledge_skills[skill.name] = skill
423
- except SkillValidationError as e:
424
- # For validation errors, include the original exception
425
- error_msg = f"Error loading skill from {file}: {str(e)}"
426
- raise SkillValidationError(error_msg) from e
427
- except Exception as e:
428
- # For other errors, wrap in a ValueError with detailed message
429
- error_msg = f"Error loading skill from {file}: {str(e)}"
430
- raise ValueError(error_msg) from e
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 {len(repo_skills) + len(knowledge_skills)} skills: "
434
- f"{[*repo_skills.keys(), *knowledge_skills.keys()]}"
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(skills_dir)
681
+ repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
682
+ skills_dir
683
+ )
468
684
 
469
- # Merge repo and knowledge skills
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(project_skills_dir)
741
+ repo_skills, knowledge_skills, agent_skills = load_skills_from_dir(
742
+ project_skills_dir
743
+ )
526
744
 
527
- # Merge repo and knowledge skills
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
- skill_dir=repo_path,
922
+ skill_base_dir=repo_path,
705
923
  )
706
924
  if skill is None:
707
925
  continue
@@ -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