Skip to content
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
SkillValidationError,
TaskTrigger,
load_skills_from_dir,
load_user_skills,
)


Expand All @@ -19,6 +20,7 @@
"TaskTrigger",
"SkillKnowledge",
"load_skills_from_dir",
"load_user_skills",
"render_template",
"SkillValidationError",
]
33 changes: 32 additions & 1 deletion openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import pathlib

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator

from openhands.sdk.context.prompts import render_template
from openhands.sdk.context.skills import (
Skill,
SkillKnowledge,
load_user_skills,
)
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.logger import get_logger
Expand Down Expand Up @@ -48,6 +49,13 @@ class AgentContext(BaseModel):
user_message_suffix: str | None = Field(
default=None, description="Optional suffix to append to the user's message."
)
load_user_skills: bool = Field(
default=False,
description=(
"Whether to automatically load user skills from ~/.openhands/skills/ "
"and ~/.openhands/microagents/ (for backward compatibility). "
),
)

@field_validator("skills")
@classmethod
Expand All @@ -62,6 +70,29 @@ def _validate_skills(cls, v: list[Skill], _info):
seen_names.add(skill.name)
return v

@model_validator(mode="after")
def _load_user_skills(self):
"""Load user skills from home directory if enabled."""
if not self.load_user_skills:
return self

try:
user_skills = load_user_skills()
# Merge user skills with explicit skills, avoiding duplicates
existing_names = {skill.name for skill in self.skills}
for user_skill in user_skills:
if user_skill.name not in existing_names:
self.skills.append(user_skill)
else:
logger.warning(
f"Skipping user skill '{user_skill.name}' "
f"(already in explicit skills)"
)
except Exception as e:
logger.warning(f"Failed to load user skills: {str(e)}")

return self

def get_system_message_suffix(self) -> str | None:
"""Get the system message with repo skill content and custom suffix.

Expand Down
7 changes: 6 additions & 1 deletion openhands-sdk/openhands/sdk/context/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from openhands.sdk.context.skills.exceptions import SkillValidationError
from openhands.sdk.context.skills.skill import Skill, load_skills_from_dir
from openhands.sdk.context.skills.skill import (
Skill,
load_skills_from_dir,
load_user_skills,
)
from openhands.sdk.context.skills.trigger import (
BaseTrigger,
KeywordTrigger,
Expand All @@ -15,5 +19,6 @@
"TaskTrigger",
"SkillKnowledge",
"load_skills_from_dir",
"load_user_skills",
"SkillValidationError",
]
50 changes: 50 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,53 @@ def load_skills_from_dir(
f"{[*repo_skills.keys(), *knowledge_skills.keys()]}"
)
return repo_skills, knowledge_skills


# Default user skills directories (in order of priority)
USER_SKILLS_DIRS = [
Path.home() / ".openhands" / "skills",
Path.home() / ".openhands" / "microagents", # Legacy support
]


def load_user_skills() -> list[Skill]:
"""Load skills from user's home directory.

Searches for skills in ~/.openhands/skills/ and ~/.openhands/microagents/
(legacy). Skills from both directories are merged, with skills/ taking
precedence for duplicate names.

Returns:
List of Skill objects loaded from user directories.
Returns empty list if no skills found or loading fails.
"""
all_skills = []
seen_names = set()

for skills_dir in USER_SKILLS_DIRS:
if not skills_dir.exists():
logger.debug(f"User skills directory does not exist: {skills_dir}")
continue

try:
logger.debug(f"Loading user skills from {skills_dir}")
repo_skills, knowledge_skills = load_skills_from_dir(skills_dir)

# Merge repo and knowledge skills
for skills_dict in [repo_skills, knowledge_skills]:
for name, skill in skills_dict.items():
if name not in seen_names:
all_skills.append(skill)
seen_names.add(name)
else:
logger.warning(
f"Skipping duplicate skill '{name}' from {skills_dir}"
)

except Exception as e:
logger.warning(f"Failed to load user skills from {skills_dir}: {str(e)}")

logger.debug(
f"Loaded {len(all_skills)} user skills: {[s.name for s in all_skills]}"
)
return all_skills
Loading
Loading