Skip to content

Commit b8de86c

Browse files
whywaitaxingyaowwopenhands-agent
authored
feat: Add automatic loading of user skills from home directory (#950)
Co-authored-by: Xingyao Wang <[email protected]> Co-authored-by: openhands <[email protected]> Co-authored-by: Xingyao Wang <[email protected]>
1 parent d5995c3 commit b8de86c

File tree

5 files changed

+364
-2
lines changed

5 files changed

+364
-2
lines changed

openhands-sdk/openhands/sdk/context/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
SkillValidationError,
99
TaskTrigger,
1010
load_skills_from_dir,
11+
load_user_skills,
1112
)
1213

1314

@@ -19,6 +20,7 @@
1920
"TaskTrigger",
2021
"SkillKnowledge",
2122
"load_skills_from_dir",
23+
"load_user_skills",
2224
"render_template",
2325
"SkillValidationError",
2426
]

openhands-sdk/openhands/sdk/context/agent_context.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import pathlib
22

3-
from pydantic import BaseModel, Field, field_validator
3+
from pydantic import BaseModel, Field, field_validator, model_validator
44

55
from openhands.sdk.context.prompts import render_template
66
from openhands.sdk.context.skills import (
77
Skill,
88
SkillKnowledge,
9+
load_user_skills,
910
)
1011
from openhands.sdk.llm import Message, TextContent
1112
from openhands.sdk.logger import get_logger
@@ -48,6 +49,13 @@ class AgentContext(BaseModel):
4849
user_message_suffix: str | None = Field(
4950
default=None, description="Optional suffix to append to the user's message."
5051
)
52+
load_user_skills: bool = Field(
53+
default=False,
54+
description=(
55+
"Whether to automatically load user skills from ~/.openhands/skills/ "
56+
"and ~/.openhands/microagents/ (for backward compatibility). "
57+
),
58+
)
5159

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

73+
@model_validator(mode="after")
74+
def _load_user_skills(self):
75+
"""Load user skills from home directory if enabled."""
76+
if not self.load_user_skills:
77+
return self
78+
79+
try:
80+
user_skills = load_user_skills()
81+
# Merge user skills with explicit skills, avoiding duplicates
82+
existing_names = {skill.name for skill in self.skills}
83+
for user_skill in user_skills:
84+
if user_skill.name not in existing_names:
85+
self.skills.append(user_skill)
86+
else:
87+
logger.warning(
88+
f"Skipping user skill '{user_skill.name}' "
89+
f"(already in explicit skills)"
90+
)
91+
except Exception as e:
92+
logger.warning(f"Failed to load user skills: {str(e)}")
93+
94+
return self
95+
6596
def get_system_message_suffix(self) -> str | None:
6697
"""Get the system message with repo skill content and custom suffix.
6798

openhands-sdk/openhands/sdk/context/skills/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from openhands.sdk.context.skills.exceptions import SkillValidationError
2-
from openhands.sdk.context.skills.skill import Skill, load_skills_from_dir
2+
from openhands.sdk.context.skills.skill import (
3+
Skill,
4+
load_skills_from_dir,
5+
load_user_skills,
6+
)
37
from openhands.sdk.context.skills.trigger import (
48
BaseTrigger,
59
KeywordTrigger,
@@ -15,5 +19,6 @@
1519
"TaskTrigger",
1620
"SkillKnowledge",
1721
"load_skills_from_dir",
22+
"load_user_skills",
1823
"SkillValidationError",
1924
]

openhands-sdk/openhands/sdk/context/skills/skill.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,53 @@ def load_skills_from_dir(
307307
f"{[*repo_skills.keys(), *knowledge_skills.keys()]}"
308308
)
309309
return repo_skills, knowledge_skills
310+
311+
312+
# Default user skills directories (in order of priority)
313+
USER_SKILLS_DIRS = [
314+
Path.home() / ".openhands" / "skills",
315+
Path.home() / ".openhands" / "microagents", # Legacy support
316+
]
317+
318+
319+
def load_user_skills() -> list[Skill]:
320+
"""Load skills from user's home directory.
321+
322+
Searches for skills in ~/.openhands/skills/ and ~/.openhands/microagents/
323+
(legacy). Skills from both directories are merged, with skills/ taking
324+
precedence for duplicate names.
325+
326+
Returns:
327+
List of Skill objects loaded from user directories.
328+
Returns empty list if no skills found or loading fails.
329+
"""
330+
all_skills = []
331+
seen_names = set()
332+
333+
for skills_dir in USER_SKILLS_DIRS:
334+
if not skills_dir.exists():
335+
logger.debug(f"User skills directory does not exist: {skills_dir}")
336+
continue
337+
338+
try:
339+
logger.debug(f"Loading user skills from {skills_dir}")
340+
repo_skills, knowledge_skills = load_skills_from_dir(skills_dir)
341+
342+
# Merge repo and knowledge skills
343+
for skills_dict in [repo_skills, knowledge_skills]:
344+
for name, skill in skills_dict.items():
345+
if name not in seen_names:
346+
all_skills.append(skill)
347+
seen_names.add(name)
348+
else:
349+
logger.warning(
350+
f"Skipping duplicate skill '{name}' from {skills_dir}"
351+
)
352+
353+
except Exception as e:
354+
logger.warning(f"Failed to load user skills from {skills_dir}: {str(e)}")
355+
356+
logger.debug(
357+
f"Loaded {len(all_skills)} user skills: {[s.name for s in all_skills]}"
358+
)
359+
return all_skills

0 commit comments

Comments
 (0)