Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 using existing loaders.
# Priority: project skills override user skills (user loaded first, project second).
resolved_skills: list = []
if agent_def.skills:
from openhands.sdk.context.skills import load_project_skills, load_user_skills

available = {s.name: s for s in load_user_skills()}
if work_dir:
available.update({s.name: s for s in load_project_skills(work_dir)})

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=list(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,
) -> 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
84 changes: 60 additions & 24 deletions openhands-sdk/openhands/sdk/subagent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,57 @@

import re
from pathlib import Path
from typing import Any
from typing import Any, Final

import frontmatter
from pydantic import BaseModel, Field


KNOWN_FIELDS: Final[set[str]] = {
"name",
"description",
"model",
"color",
"tools",
"skills",
}


def _extract_color(fm: dict[str, object]) -> str | None:
"""Extract color from frontmatter."""
color_raw = fm.get("color")
color: str | None = str(color_raw) if color_raw is not None else None
return color


def _extract_tools(fm: dict[str, object]) -> list[str]:
"""Extract tools from frontmatter."""
tools_raw = fm.get("tools", [])

# Ensure tools is a list of strings
tools: list[str]
if isinstance(tools_raw, str):
tools = [tools_raw]
elif isinstance(tools_raw, list):
tools = [str(t) for t in tools_raw]
else:
tools = []
return tools


def _extract_skills(fm: dict[str, object]) -> list[str]:
"""Extract skill names from frontmatter."""
skills_raw = fm.get("skills", [])
skills: list[str]
if isinstance(skills_raw, str):
skills = [s.strip() for s in skills_raw.split(",") if s.strip()]
elif isinstance(skills_raw, list):
skills = [str(s) for s in skills_raw]
else:
skills = []
return skills


def _extract_examples(description: str) -> list[str]:
"""Extract <example> tags from description for agent triggering."""
pattern = r"<example>(.*?)</example>"
Expand All @@ -33,6 +78,11 @@ class AgentDefinition(BaseModel):
tools: list[str] = Field(
default_factory=list, description="List of allowed tools for this agent"
)
skills: list[str] = Field(
default_factory=list,
description="List of skill names for this agent. "
"Resolved from project/user directories.",
)
system_prompt: str = Field(default="", description="System prompt content")
source: str | None = Field(
default=None, description="Source file path for this agent"
Expand All @@ -53,6 +103,7 @@ def load(cls, agent_path: Path) -> AgentDefinition:
- name: Agent name
- description: Description with optional <example> tags for triggering
- tools (optional): List of allowed tools
- skills (optional): Comma-separated skill names or list of skill names
- model (optional): Model profile to use (default: 'inherit')
- color (optional): Display color

Expand All @@ -71,41 +122,26 @@ def load(cls, agent_path: Path) -> AgentDefinition:
content = post.content.strip()

# Extract frontmatter fields with proper type handling
name = str(fm.get("name", agent_path.stem))
description = str(fm.get("description", ""))
model = str(fm.get("model", "inherit"))
color_raw = fm.get("color")
color: str | None = str(color_raw) if color_raw is not None else None
tools_raw = fm.get("tools", [])

# Ensure tools is a list of strings
tools: list[str]
if isinstance(tools_raw, str):
tools = [tools_raw]
elif isinstance(tools_raw, list):
tools = [str(t) for t in tools_raw]
else:
tools = []
name: str = str(fm.get("name", agent_path.stem))
description: str = str(fm.get("description", ""))
model: str = str(fm.get("model", "inherit"))
color: str | None = _extract_color(fm)
tools: list[str] = _extract_tools(fm)
skills: list[str] = _extract_skills(fm)

# Extract whenToUse examples from description
when_to_use_examples = _extract_examples(description)

# Remove known fields from metadata to get extras
known_fields = {
"name",
"description",
"model",
"color",
"tools",
}
metadata = {k: v for k, v in fm.items() if k not in known_fields}
metadata = {k: v for k, v in fm.items() if k not in KNOWN_FIELDS}

return cls(
name=name,
description=description,
model=model,
color=color,
tools=tools,
skills=skills,
system_prompt=content,
source=str(agent_path),
when_to_use_examples=when_to_use_examples,
Expand Down
Loading
Loading