Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ def _merge_plugin_into_request(
) -> StartConversationRequest:
"""Merge plugin skills, hooks, and MCP config into the request.

This method merges both explicit skills from the skills/ directory
and command-derived skills from the commands/ directory. Commands
are converted to keyword-triggered skills using the Claude Code
namespacing format: /<plugin-name>:<command-name>

Args:
request: The original start conversation request
plugin: The loaded plugin
Expand All @@ -311,14 +316,22 @@ def _merge_plugin_into_request(
agent = request.agent
updates: dict = {}

if plugin.skills:
if len(plugin.skills) > self.MAX_PLUGIN_SKILLS:
# Get all skills including those converted from commands
all_plugin_skills = plugin.get_all_skills()

if all_plugin_skills:
if len(all_plugin_skills) > self.MAX_PLUGIN_SKILLS:
raise PluginFetchError(
f"Plugin has too many skills "
f"({len(plugin.skills)} > {self.MAX_PLUGIN_SKILLS})"
f"({len(all_plugin_skills)} > {self.MAX_PLUGIN_SKILLS})"
)
updates["agent_context"] = self._merge_skills(
agent.agent_context, plugin.skills
agent.agent_context, all_plugin_skills
)
logger.info(
f"Merged {len(all_plugin_skills)} plugin skills "
f"({len(plugin.skills)} from skills/, "
f"{len(plugin.commands)} from commands/) into agent context"
)

if plugin.mcp_config:
Expand Down
19 changes: 19 additions & 0 deletions openhands-sdk/openhands/sdk/plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ def description(self) -> str:
"""Get the plugin description."""
return self.manifest.description

def get_all_skills(self) -> list[Skill]:
"""Get all skills including those converted from commands.

Returns skills from both the skills/ directory and commands/ directory.
Commands are converted to keyword-triggered skills using the format
/<plugin-name>:<command-name>.

Returns:
Combined list of skills (original + command-derived skills).
"""
all_skills = list(self.skills)

# Convert commands to skills with keyword triggers
for command in self.commands:
skill = command.to_skill(self.name)
all_skills.append(skill)

return all_skills

@classmethod
def fetch(
cls,
Expand Down
53 changes: 52 additions & 1 deletion openhands-sdk/openhands/sdk/plugin/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

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

import frontmatter
from pydantic import BaseModel, Field


if TYPE_CHECKING:
from openhands.sdk.context.skills import Skill


class PluginAuthor(BaseModel):
"""Author information for a plugin."""

Expand Down Expand Up @@ -224,3 +228,50 @@ def load(cls, command_path: Path) -> CommandDefinition:
source=str(command_path),
metadata=metadata,
)

def to_skill(self, plugin_name: str) -> Skill:
"""Convert this command to a keyword-triggered Skill.

Creates a Skill with a KeywordTrigger using the Claude Code namespacing
format: /<plugin-name>:<command-name>

Args:
plugin_name: The name of the plugin this command belongs to.

Returns:
A Skill object with the command content and a KeywordTrigger.

Example:
For a plugin "city-weather" with command "now":
- Trigger keyword: "/city-weather:now"
- When user types "/city-weather:now Tokyo", the skill activates
"""
from openhands.sdk.context.skills import Skill
from openhands.sdk.context.skills.trigger import KeywordTrigger

# Build the trigger keyword in Claude Code namespace format
trigger_keyword = f"/{plugin_name}:{self.name}"

# Build skill content with $ARGUMENTS placeholder context
content_parts = []
if self.description:
content_parts.append(f"## {self.name}\n\n{self.description}\n")

if self.argument_hint:
content_parts.append(
f"**Arguments**: `$ARGUMENTS` - {self.argument_hint}\n"
)

if self.content:
content_parts.append(f"\n{self.content}")

skill_content = "\n".join(content_parts).strip()

return Skill(
name=f"{plugin_name}:{self.name}",
content=skill_content,
description=self.description or f"Command {self.name} from {plugin_name}",
trigger=KeywordTrigger(keywords=[trigger_keyword]),
source=self.source,
allowed_tools=self.allowed_tools if self.allowed_tools else None,
)
104 changes: 104 additions & 0 deletions tests/agent_server/test_conversation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,110 @@ def test_merge_plugin_into_request_empty_plugin(self, conversation_service):
# mcp_config may default to {} in Agent, so check it wasn't modified from plugin
# The key point is no plugin content was added

def test_merge_plugin_into_request_with_commands(self, conversation_service):
"""Test merging plugin commands as keyword-triggered skills."""
from openhands.sdk.context.skills.trigger import KeywordTrigger
from openhands.sdk.plugin import Plugin
from openhands.sdk.plugin.types import CommandDefinition, PluginManifest

# Create a plugin with commands (no skills)
plugin_with_commands = Plugin(
manifest=PluginManifest(
name="city-weather",
version="1.0.0",
description="Weather plugin",
),
path="/tmp/city-weather",
skills=[],
hooks=None,
mcp_config=None,
agents=[],
commands=[
CommandDefinition(
name="now",
description="Get current weather for a city",
argument_hint="<city-name>",
allowed_tools=["tavily_search"],
content="Fetch the current weather.",
)
],
)

request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="/tmp/test"),
)

result = conversation_service._merge_plugin_into_request(
request, plugin_with_commands
)

# Verify command was converted to skill
assert result.agent.agent_context is not None
assert len(result.agent.agent_context.skills) == 1

skill = result.agent.agent_context.skills[0]
assert skill.name == "city-weather:now"
assert isinstance(skill.trigger, KeywordTrigger)
assert "/city-weather:now" in skill.trigger.keywords

def test_merge_plugin_into_request_with_skills_and_commands(
self, conversation_service
):
"""Test merging plugin with both skills and commands."""
from openhands.sdk.context.skills import Skill
from openhands.sdk.context.skills.trigger import KeywordTrigger
from openhands.sdk.plugin import Plugin
from openhands.sdk.plugin.types import CommandDefinition, PluginManifest

plugin = Plugin(
manifest=PluginManifest(
name="full-plugin",
version="1.0.0",
description="Plugin with skills and commands",
),
path="/tmp/full-plugin",
skills=[
Skill(name="regular-skill", content="Regular skill content"),
],
hooks=None,
mcp_config=None,
agents=[],
commands=[
CommandDefinition(
name="cmd1",
description="First command",
content="Command 1 content.",
),
CommandDefinition(
name="cmd2",
description="Second command",
content="Command 2 content.",
),
],
)

request = StartConversationRequest(
agent=Agent(llm=LLM(model="gpt-4", usage_id="test-llm"), tools=[]),
workspace=LocalWorkspace(working_dir="/tmp/test"),
)

result = conversation_service._merge_plugin_into_request(request, plugin)

# Verify all skills (1 regular + 2 from commands)
assert result.agent.agent_context is not None
assert len(result.agent.agent_context.skills) == 3

skill_names = {s.name for s in result.agent.agent_context.skills}
assert "regular-skill" in skill_names
assert "full-plugin:cmd1" in skill_names
assert "full-plugin:cmd2" in skill_names

# Verify command-derived skills have keyword triggers
cmd_skills = [s for s in result.agent.agent_context.skills if ":" in s.name]
for cmd_skill in cmd_skills:
assert isinstance(cmd_skill.trigger, KeywordTrigger)

@patch("openhands.agent_server.conversation_service.Plugin")
def test_load_and_merge_plugin_success(
self, mock_plugin_class, conversation_service, mock_plugin
Expand Down
135 changes: 135 additions & 0 deletions tests/sdk/plugin/test_plugin_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,141 @@ def test_load_plugin_with_commands(self, tmp_path: Path):
assert "Read" in command.allowed_tools
assert "Review the specified code" in command.content

def test_command_to_skill_conversion(self, tmp_path: Path):
"""Test converting a command to a keyword-triggered skill."""
from openhands.sdk.context.skills.trigger import KeywordTrigger

plugin_dir = tmp_path / "city-weather"
plugin_dir.mkdir()
manifest_dir = plugin_dir / ".plugin"
manifest_dir.mkdir()
manifest_file = manifest_dir / "plugin.json"
manifest_file.write_text('{"name": "city-weather", "version": "1.0.0"}')

commands_dir = plugin_dir / "commands"
commands_dir.mkdir()
command_md = commands_dir / "now.md"
command_md.write_text(
"""---
description: Get current weather for a city
argument-hint: <city-name>
allowed-tools:
- tavily_search
---

Fetch and display the current weather for the specified city.
"""
)

plugin = Plugin.load(plugin_dir)
assert len(plugin.commands) == 1

# Convert command to skill
command = plugin.commands[0]
skill = command.to_skill("city-weather")

# Verify skill properties
assert skill.name == "city-weather:now"
assert skill.description == "Get current weather for a city"
assert skill.allowed_tools is not None
assert "tavily_search" in skill.allowed_tools

# Verify trigger format
assert isinstance(skill.trigger, KeywordTrigger)
assert "/city-weather:now" in skill.trigger.keywords

# Verify content includes argument hint
assert "$ARGUMENTS" in skill.content
assert "Fetch and display the current weather" in skill.content

def test_get_all_skills_with_commands(self, tmp_path: Path):
"""Test get_all_skills returns both skills and command-derived skills."""
from openhands.sdk.context.skills.trigger import KeywordTrigger

plugin_dir = tmp_path / "test-plugin"
plugin_dir.mkdir()
manifest_dir = plugin_dir / ".plugin"
manifest_dir.mkdir()
manifest_file = manifest_dir / "plugin.json"
manifest_file.write_text('{"name": "test-plugin", "version": "1.0.0"}')

# Create skills directory with a skill
skills_dir = plugin_dir / "skills"
skills_dir.mkdir()
skill_dir = skills_dir / "my-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"""---
name: my-skill
description: A regular skill
---

This is a regular skill content.
"""
)

# Create commands directory with a command
commands_dir = plugin_dir / "commands"
commands_dir.mkdir()
command_md = commands_dir / "greet.md"
command_md.write_text(
"""---
description: Greet someone
argument-hint: <name>
---

Say hello to the specified person.
"""
)

plugin = Plugin.load(plugin_dir)

# Verify separate counts
assert len(plugin.skills) == 1
assert len(plugin.commands) == 1

# Verify combined skills
all_skills = plugin.get_all_skills()
assert len(all_skills) == 2

# Find the regular skill and command-derived skill
skill_names = {s.name for s in all_skills}
assert "my-skill" in skill_names
assert "test-plugin:greet" in skill_names

# Verify command-derived skill has keyword trigger
command_skill = next(s for s in all_skills if s.name == "test-plugin:greet")
assert isinstance(command_skill.trigger, KeywordTrigger)
assert "/test-plugin:greet" in command_skill.trigger.keywords

def test_get_all_skills_empty_commands(self, tmp_path: Path):
"""Test get_all_skills with no commands."""
plugin_dir = tmp_path / "no-commands"
plugin_dir.mkdir()

# Create skills directory with a skill only
skills_dir = plugin_dir / "skills"
skills_dir.mkdir()
skill_dir = skills_dir / "only-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"""---
name: only-skill
description: The only skill
---

Content for the only skill.
"""
)

plugin = Plugin.load(plugin_dir)

all_skills = plugin.get_all_skills()
assert len(all_skills) == 1
assert all_skills[0].name == "only-skill"

def test_load_all_plugins(self, tmp_path: Path):
"""Test loading all plugins from a directory."""
plugins_dir = tmp_path / "plugins"
Expand Down
Loading