Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions examples/01_standalone_sdk/03_activate_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
system_message_suffix="Always finish your response with the word 'yay!'",
# user_message_suffix is appended to each user message
user_message_suffix="The first character of your response should be 'I'",
# You can also enable automatic load skills from
# public registry at https://github.com/OpenHands/skills
load_public_skills=True,
)

# Agent
Expand Down Expand Up @@ -114,6 +117,13 @@ def conversation_callback(event: Event):
conversation.send_message("flarglebargle!")
conversation.run()

print("=" * 100)
print("Now triggering public skill 'github'")
conversation.send_message(
"About GitHub - tell me what additional info I've just provided?"
)
conversation.run()

print("=" * 100)
print("Conversation finished. Got the following LLM messages:")
for i, message in enumerate(llm_messages):
Expand Down
30 changes: 30 additions & 0 deletions openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from openhands.sdk.context.skills import (
Skill,
SkillKnowledge,
load_public_skills,
load_user_skills,
)
from openhands.sdk.llm import Message, TextContent
Expand Down Expand Up @@ -56,6 +57,14 @@ class AgentContext(BaseModel):
"and ~/.openhands/microagents/ (for backward compatibility). "
),
)
load_public_skills: bool = Field(
default=False,
description=(
"Whether to automatically load skills from the public OpenHands "
"skills repository at https://github.com/OpenHands/skills. "
"This allows you to get the latest skills without SDK updates."
),
)

@field_validator("skills")
@classmethod
Expand Down Expand Up @@ -93,6 +102,27 @@ def _load_user_skills(self):

return self

@model_validator(mode="after")
def _load_public_skills(self):
"""Load public skills from OpenHands skills repository if enabled."""
if not self.load_public_skills:
return self
try:
public_skills = load_public_skills()
# Merge public skills with explicit skills, avoiding duplicates
existing_names = {skill.name for skill in self.skills}
for public_skill in public_skills:
if public_skill.name not in existing_names:
self.skills.append(public_skill)
else:
logger.warning(
f"Skipping public skill '{public_skill.name}' "
f"(already in existing skills)"
)
except Exception as e:
logger.warning(f"Failed to load public 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
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from openhands.sdk.context.skills.exceptions import SkillValidationError
from openhands.sdk.context.skills.skill import (
Skill,
load_public_skills,
load_skills_from_dir,
load_user_skills,
)
Expand All @@ -20,5 +21,6 @@
"SkillKnowledge",
"load_skills_from_dir",
"load_user_skills",
"load_public_skills",
"SkillValidationError",
]
173 changes: 173 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import io
import re
import shutil
import subprocess
from itertools import chain
from pathlib import Path
from typing import Annotated, ClassVar, Union
Expand Down Expand Up @@ -366,3 +368,174 @@ def load_user_skills() -> list[Skill]:
f"Loaded {len(all_skills)} user skills: {[s.name for s in all_skills]}"
)
return all_skills


# Public skills repository configuration
PUBLIC_SKILLS_REPO = "https://github.com/OpenHands/skills"
PUBLIC_SKILLS_BRANCH = "main"


def _get_skills_cache_dir() -> Path:
"""Get the local cache directory for public skills repository.

Returns:
Path to the skills cache directory (~/.openhands/cache/skills).
"""
cache_dir = Path.home() / ".openhands" / "cache" / "skills"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir


def _update_skills_repository(
repo_url: str,
branch: str,
cache_dir: Path,
) -> Path | None:
"""Clone or update the local skills repository.

Args:
repo_url: URL of the skills repository.
branch: Branch name to use.
cache_dir: Directory where the repository should be cached.

Returns:
Path to the local repository if successful, None otherwise.
"""
repo_path = cache_dir / "public-skills"

try:
if repo_path.exists() and (repo_path / ".git").exists():
logger.debug(f"Updating skills repository at {repo_path}")
try:
subprocess.run(
["git", "fetch", "origin"],
cwd=repo_path,
check=True,
capture_output=True,
timeout=30,
)
subprocess.run(
["git", "reset", "--hard", f"origin/{branch}"],
cwd=repo_path,
check=True,
capture_output=True,
timeout=10,
)
logger.debug("Skills repository updated successfully")
except subprocess.TimeoutExpired:
logger.warning("Git pull timed out, using existing cached repository")
except subprocess.CalledProcessError as e:
logger.warning(
f"Failed to update repository: {e.stderr.decode()}, "
f"using existing cached version"
)
else:
logger.info(f"Cloning public skills repository from {repo_url}")
if repo_path.exists():
shutil.rmtree(repo_path)

subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--branch",
branch,
repo_url,
str(repo_path),
],
check=True,
capture_output=True,
timeout=60,
)
logger.debug(f"Skills repository cloned to {repo_path}")

return repo_path

except subprocess.TimeoutExpired:
logger.warning(f"Git operation timed out for {repo_url}")
return None
except subprocess.CalledProcessError as e:
logger.warning(
f"Failed to clone/update repository {repo_url}: {e.stderr.decode()}"
)
return None
except Exception as e:
logger.warning(f"Error managing skills repository: {str(e)}")
return None


def load_public_skills(
repo_url: str = PUBLIC_SKILLS_REPO,
branch: str = PUBLIC_SKILLS_BRANCH,
) -> list[Skill]:
"""Load skills from the public OpenHands skills repository.

This function maintains a local git clone of the public skills registry at
https://github.com/OpenHands/skills. On first run, it clones the repository
to ~/.openhands/skills-cache/. On subsequent runs, it pulls the latest changes
to keep the skills up-to-date. This approach is more efficient than fetching
individual files via HTTP.

Args:
repo_url: URL of the skills repository. Defaults to the official
OpenHands skills repository.
branch: Branch name to load skills from. Defaults to 'main'.

Returns:
List of Skill objects loaded from the public repository.
Returns empty list if loading fails.

Example:
>>> from openhands.sdk.context import AgentContext
>>> from openhands.sdk.context.skills import load_public_skills
>>>
>>> # Load public skills
>>> public_skills = load_public_skills()
>>>
>>> # Use with AgentContext
>>> context = AgentContext(skills=public_skills)
"""
all_skills = []

try:
# Get or update the local repository
cache_dir = _get_skills_cache_dir()
repo_path = _update_skills_repository(repo_url, branch, cache_dir)

if repo_path is None:
logger.warning("Failed to access public skills repository")
return all_skills

# Load skills from the local repository
skills_dir = repo_path / "skills"
if not skills_dir.exists():
logger.warning(f"Skills directory not found in repository: {skills_dir}")
return all_skills

# Find all .md files in the skills directory
md_files = [f for f in skills_dir.rglob("*.md") if f.name != "README.md"]

logger.info(f"Found {len(md_files)} skill files in public skills repository")

# Load each skill file
for skill_file in md_files:
try:
skill = Skill.load(
path=skill_file,
skill_dir=repo_path,
)
all_skills.append(skill)
logger.debug(f"Loaded public skill: {skill.name}")
except Exception as e:
logger.warning(f"Failed to load skill from {skill_file.name}: {str(e)}")
continue

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

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