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
49 changes: 17 additions & 32 deletions openhands-agent-server/openhands/agent_server/skills_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@

from openhands.sdk.context.skills import (
Skill,
load_project_skills,
load_public_skills,
load_user_skills,
load_available_skills,
)
from openhands.sdk.context.skills.skill import (
PUBLIC_SKILLS_BRANCH,
Expand Down Expand Up @@ -322,27 +320,15 @@ def load_all_skills(
sources["sandbox"] = len(sandbox_skills)
skill_lists.append(sandbox_skills)

# 2. Load public skills
public_skills: list[Skill] = []
if load_public:
try:
public_skills = load_public_skills()
logger.info(f"Loaded {len(public_skills)} public skills")
except Exception as e:
logger.warning(f"Failed to load public skills: {e}")
sources["public"] = len(public_skills)
skill_lists.append(public_skills)

# 3. Load user skills
user_skills: list[Skill] = []
if load_user:
try:
user_skills = load_user_skills()
logger.info(f"Loaded {len(user_skills)} user skills")
except Exception as e:
logger.warning(f"Failed to load user skills: {e}")
sources["user"] = len(user_skills)
skill_lists.append(user_skills)
# 2-3. Load public + user skills via helper (no project yet — org sits between)
sdk_base = load_available_skills(
work_dir=None,
include_user=load_user,
include_project=False,
include_public=load_public,
)
sources["sdk_base"] = len(sdk_base)
skill_lists.append(list(sdk_base.values()))

# 4. Load organization skills
org_skills: list[Skill] = []
Expand All @@ -359,15 +345,14 @@ def load_all_skills(
skill_lists.append(org_skills)

# 5. Load project skills (highest precedence)
project_skills: list[Skill] = []
if load_project and project_dir:
try:
project_skills = load_project_skills(project_dir)
logger.info(f"Loaded {len(project_skills)} project skills")
except Exception as e:
logger.warning(f"Failed to load project skills: {e}")
project_skills = load_available_skills(
work_dir=project_dir if load_project else None,
include_user=False,
include_project=load_project,
include_public=False,
)
sources["project"] = len(project_skills)
skill_lists.append(project_skills)
skill_lists.append(list(project_skills.values()))

# Merge all skills with precedence
all_skills = merge_skills(skill_lists)
Expand Down
57 changes: 18 additions & 39 deletions openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from openhands.sdk.context.skills import (
Skill,
SkillKnowledge,
load_public_skills,
load_user_skills,
load_available_skills,
to_prompt,
)
from openhands.sdk.llm import Message, TextContent
Expand Down Expand Up @@ -106,47 +105,27 @@ def _validate_skills(cls, v: list[Skill], _info):
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:
def _load_auto_skills(self):
"""Load user and/or public skills if enabled."""
if not self.load_user_skills and not self.load_public_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)}")
auto_skills = load_available_skills(
work_dir=None,
include_user=self.load_user_skills,
include_project=False,
include_public=self.load_public_skills,
)

return self
existing_names = {skill.name for skill in self.skills}
for name, skill in auto_skills.items():
if name not in existing_names:
self.skills.append(skill)
else:
logger.warning(
f"Skipping auto-loaded skill '{name}' (already in explicit skills)"
)

@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_secret_infos(self) -> list[dict[str, str | None]]:
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
Expand Up @@ -2,6 +2,7 @@
from openhands.sdk.context.skills.skill import (
Skill,
SkillResources,
load_available_skills,
load_project_skills,
load_public_skills,
load_skills_from_dir,
Expand All @@ -28,6 +29,7 @@
"KeywordTrigger",
"TaskTrigger",
"SkillKnowledge",
"load_available_skills",
"load_skills_from_dir",
"load_user_skills",
"load_project_skills",
Expand Down
53 changes: 53 additions & 0 deletions openhands-sdk/openhands/sdk/context/skills/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,59 @@ def load_public_skills(
return all_skills


def load_available_skills(
work_dir: str | Path | None = None,
*,
include_user: bool = False,
include_project: bool = False,
include_public: bool = False,
) -> dict[str, Skill]:
"""Load and merge skills from SDK-level sources with consistent precedence.

Precedence (later overrides earlier via dict updates):
public (lowest) → user → project (highest)

This is the single entry-point for building a merged skill catalog from
the three SDK-shipped sources. Server-only sources (sandbox, org) are
layered on top by the caller.

Args:
work_dir: Project/working directory for project skills. When None,
project skills are skipped regardless of *include_project*.
include_user: Load user-level skills (~/.agents/skills, etc.).
include_project: Load project-level skills (requires *work_dir*).
include_public: Load public skills from the OpenHands extensions repo.

Returns:
Dict mapping skill name → Skill, with higher-precedence sources
overriding lower ones.
"""
available: dict[str, Skill] = {}

if include_public:
try:
for s in load_public_skills():
available[s.name] = s
except Exception as e:
logger.warning(f"Failed to load public skills: {e}")

if include_user:
try:
for s in load_user_skills():
available[s.name] = s
except Exception as e:
logger.warning(f"Failed to load user skills: {e}")

if include_project and work_dir:
try:
for s in load_project_skills(work_dir):
available[s.name] = s
except Exception as e:
logger.warning(f"Failed to load project skills: {e}")

return available


def to_prompt(skills: list[Skill], max_description_length: int = 200) -> str:
"""Generate XML prompt block for available skills.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,10 @@ def _ensure_plugins_loaded(self) -> None:

# Register file-based agents defined in plugins
if all_plugin_agents:
register_plugin_agents(all_plugin_agents)
register_plugin_agents(
agents=all_plugin_agents,
work_dir=self.workspace.working_dir,
)

# Combine explicit hook_config with plugin hooks
# Explicit hooks run first (before plugin hooks)
Expand Down
46 changes: 40 additions & 6 deletions openhands-sdk/openhands/sdk/subagent/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,46 @@ def register_agent_if_absent(

def agent_definition_to_factory(
agent_def: AgentDefinition,
work_dir: str | Path | None = None,
) -> Callable[["LLM"], "Agent"]:
"""Create an agent factory closure from an `AgentDefinition`.

The returned callable accepts an `LLM` instance (the parent agent's LLM)
and builds a fully-configured `Agent` instance.

- Tool names from `agent_def.tools` are mapped to `Tool` objects.
- Skill names from `agent_def.skills` are resolved to `Skill` objects
from project and user skill directories (project takes priority).
- The system prompt is set as the `system_message_suffix` on the
`AgentContext`.
- `model: inherit` preserves the parent LLM; an explicit model name
creates a copy via `model_copy(update=...)`.

Args:
agent_def: The agent definition to convert.
work_dir: Project directory for resolving skill names. If None,
only user-level skills are searched.

Raises:
ValueError: If a tool provided to the agent is not registered.
ValueError: If a tool or skill is not found.
"""
# Resolve skills eagerly at factory creation time.
# Priority: project skills override user skills (handled by load_available_skills).
resolved_skills: list = []
if agent_def.skills:
from openhands.sdk.context.skills import load_available_skills

available = load_available_skills(
work_dir, include_user=True, include_project=True, include_public=False
)

for name in agent_def.skills:
if name not in available:
raise ValueError(
f"Skill '{name}' not found but was given to agent "
f"'{agent_def.name}'."
)
resolved_skills.append(available[name])

def _factory(llm: "LLM") -> "Agent":
from openhands.sdk.agent.agent import Agent
Expand All @@ -141,9 +166,13 @@ def _factory(llm: "LLM") -> "Agent":

# the system prompt of the subagent is added as a suffix of the
# main system prompt
has_context = agent_def.system_prompt or resolved_skills
agent_context = (
AgentContext(system_message_suffix=agent_def.system_prompt)
if agent_def.system_prompt
AgentContext(
system_message_suffix=agent_def.system_prompt or None,
skills=resolved_skills,
)
if has_context
else None
)

Expand Down Expand Up @@ -199,7 +228,7 @@ def register_file_agents(work_dir: str | Path) -> list[str]:

registered: list[str] = []
for agent_def in deduplicated:
factory = agent_definition_to_factory(agent_def)
factory = agent_definition_to_factory(agent_def, work_dir=work_dir)
was_registered = register_agent_if_absent(
name=agent_def.name,
factory_func=factory,
Expand All @@ -215,7 +244,10 @@ def register_file_agents(work_dir: str | Path) -> list[str]:
return registered


def register_plugin_agents(agents: list[AgentDefinition]) -> list[str]:
def register_plugin_agents(
agents: list[AgentDefinition],
work_dir: str | Path | None = None,
) -> list[str]:
"""Register plugin-provided agent definitions into the delegate registry.

Plugin agents have higher priority than file-based agents but lower than
Expand All @@ -225,13 +257,15 @@ def register_plugin_agents(agents: list[AgentDefinition]) -> list[str]:

Args:
agents: Agent definitions collected from loaded plugins.
work_dir: Project directory for resolving skill names in agent
definitions. If None, only user-level skills are searched.

Returns:
List of agent names that were actually registered.
"""
registered: list[str] = []
for agent_def in agents:
factory = agent_definition_to_factory(agent_def)
factory = agent_definition_to_factory(agent_def, work_dir=work_dir)
was_registered = register_agent_if_absent(
name=agent_def.name,
factory_func=factory,
Expand Down
Loading
Loading