From e2a27c42574783c373b1d1ef2da67bedad98b645 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 19:03:45 +0000 Subject: [PATCH 01/20] feat(plugin): Add MarketplaceRegistration and MarketplaceRegistry for multiple marketplace support This introduces support for registering multiple marketplaces with explicit auto-load semantics, replacing the single marketplace_path string. Key changes: - Add MarketplaceRegistration model with name, source, ref, repo_path, and auto_load fields - Add MarketplaceRegistry class for lazy fetching, caching, and plugin resolution - Add registered_marketplaces field to AgentContext - Deprecate marketplace_path field (kept for backward compatibility) - Add comprehensive tests for the new functionality Plugin resolution supports: - Explicit marketplace qualifier: 'plugin-name@marketplace-name' - Search all registered marketplaces: 'plugin-name' (errors if ambiguous) Closes #2494 --- .../openhands/sdk/context/agent_context.py | 32 +- .../openhands/sdk/plugin/__init__.py | 15 + .../openhands/sdk/plugin/registry.py | 283 +++++++++++++++ openhands-sdk/openhands/sdk/plugin/types.py | 76 +++- tests/sdk/plugin/test_marketplace_registry.py | 334 ++++++++++++++++++ 5 files changed, 738 insertions(+), 2 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/plugin/registry.py create mode 100644 tests/sdk/plugin/test_marketplace_registry.py diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index 9c07613321..6222f29e3c 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -1,6 +1,7 @@ from __future__ import annotations import pathlib +import warnings from collections.abc import Mapping from datetime import datetime @@ -17,11 +18,16 @@ from openhands.sdk.llm import Message, TextContent from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec from openhands.sdk.logger import get_logger +from openhands.sdk.plugin.types import MarketplaceRegistration from openhands.sdk.secret import SecretSource, SecretValue logger = get_logger(__name__) +# Constants for default marketplace +PUBLIC_SKILLS_REPO = "github:OpenHands/extensions" +PUBLIC_SKILLS_BRANCH = "main" + PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" / "templates" @@ -74,11 +80,21 @@ class AgentContext(BaseModel): ) marketplace_path: str | None = Field( default=DEFAULT_MARKETPLACE_PATH, + deprecated=True, description=( + "DEPRECATED: Use registered_marketplaces instead. " "Relative marketplace JSON path within the public skills repository. " "Set to None to load all public skills without marketplace filtering." ), ) + registered_marketplaces: list[MarketplaceRegistration] = Field( + default_factory=list, + description=( + "List of marketplace registrations for plugin resolution. " + "Marketplaces with auto_load='all' will have their plugins loaded " + "automatically at conversation start. See MarketplaceRegistration for details." + ), + ) secrets: Mapping[str, SecretValue] | None = Field( default=None, description=( @@ -118,12 +134,26 @@ def _load_auto_skills(self): if not self.load_user_skills and not self.load_public_skills: return self + # Handle backward compatibility: if marketplace_path is set but + # registered_marketplaces is empty, emit deprecation warning and + # convert to a default marketplace registration + effective_marketplace_path = self.marketplace_path + if self.marketplace_path is not None and not self.registered_marketplaces: + warnings.warn( + "AgentContext.marketplace_path is deprecated. " + "Use registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + # For backward compatibility, we still use marketplace_path + # when registered_marketplaces is empty + auto_skills = load_available_skills( work_dir=None, include_user=self.load_user_skills, include_project=False, include_public=self.load_public_skills, - marketplace_path=self.marketplace_path, + marketplace_path=effective_marketplace_path, ) existing_names = {skill.name for skill in self.skills} diff --git a/openhands-sdk/openhands/sdk/plugin/__init__.py b/openhands-sdk/openhands/sdk/plugin/__init__.py index 7b67494994..2ff77f0044 100644 --- a/openhands-sdk/openhands/sdk/plugin/__init__.py +++ b/openhands-sdk/openhands/sdk/plugin/__init__.py @@ -29,6 +29,13 @@ ) from openhands.sdk.plugin.loader import load_plugins from openhands.sdk.plugin.plugin import Plugin +from openhands.sdk.plugin.registry import ( + AmbiguousPluginError, + MarketplaceNotFoundError, + MarketplaceRegistry, + PluginNotFoundError, + PluginResolutionError, +) from openhands.sdk.plugin.source import ( GitHubURLComponents, is_local_path, @@ -44,6 +51,7 @@ MarketplaceOwner, MarketplacePluginEntry, MarketplacePluginSource, + MarketplaceRegistration, PluginAuthor, PluginManifest, PluginSource, @@ -70,6 +78,13 @@ "MarketplacePluginEntry", "MarketplacePluginSource", "MarketplaceMetadata", + # Marketplace registration and resolution + "MarketplaceRegistration", + "MarketplaceRegistry", + "PluginResolutionError", + "PluginNotFoundError", + "AmbiguousPluginError", + "MarketplaceNotFoundError", # Source path utilities "GitHubURLComponents", "parse_github_url", diff --git a/openhands-sdk/openhands/sdk/plugin/registry.py b/openhands-sdk/openhands/sdk/plugin/registry.py new file mode 100644 index 0000000000..df7f2e5dcd --- /dev/null +++ b/openhands-sdk/openhands/sdk/plugin/registry.py @@ -0,0 +1,283 @@ +"""Marketplace registry for managing registered marketplaces and plugin resolution.""" + +from __future__ import annotations + +from pathlib import Path + +from openhands.sdk.logger import get_logger +from openhands.sdk.plugin.fetch import fetch_plugin_with_resolution +from openhands.sdk.plugin.types import ( + Marketplace, + MarketplaceRegistration, + PluginSource, +) + + +logger = get_logger(__name__) + + +class PluginResolutionError(Exception): + """Raised when a plugin cannot be resolved from registered marketplaces.""" + + pass + + +class AmbiguousPluginError(PluginResolutionError): + """Raised when a plugin name matches multiple marketplaces.""" + + def __init__(self, plugin_name: str, matching_marketplaces: list[str]): + self.plugin_name = plugin_name + self.matching_marketplaces = matching_marketplaces + super().__init__( + f"Plugin '{plugin_name}' is ambiguous - found in multiple marketplaces: " + f"{', '.join(matching_marketplaces)}. " + f"Use explicit format: '{plugin_name}@'" + ) + + +class PluginNotFoundError(PluginResolutionError): + """Raised when a plugin cannot be found in any registered marketplace.""" + + def __init__(self, plugin_name: str, marketplace_name: str | None = None): + self.plugin_name = plugin_name + self.marketplace_name = marketplace_name + if marketplace_name: + msg = f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'" + else: + msg = f"Plugin '{plugin_name}' not found in any registered marketplace" + super().__init__(msg) + + +class MarketplaceNotFoundError(PluginResolutionError): + """Raised when a referenced marketplace is not registered.""" + + def __init__(self, marketplace_name: str): + self.marketplace_name = marketplace_name + super().__init__(f"Marketplace '{marketplace_name}' is not registered") + + +class MarketplaceRegistry: + """Manages registered marketplaces with lazy fetching and plugin resolution. + + The registry stores marketplace registrations and provides: + - Lazy fetching: Marketplaces are only fetched when first needed + - Caching: Fetched marketplaces are cached for the session + - Plugin resolution: Resolve plugin references like 'plugin-name@marketplace' + + Example: + >>> registry = MarketplaceRegistry([ + ... MarketplaceRegistration( + ... name="public", + ... source="github:OpenHands/skills", + ... auto_load="all" + ... ), + ... MarketplaceRegistration( + ... name="team", + ... source="github:acme/plugins" + ... ), + ... ]) + >>> # Resolve a plugin from a specific marketplace + >>> source = registry.resolve_plugin("formatter@team") + >>> # Resolve a plugin, searching all marketplaces + >>> source = registry.resolve_plugin("git") + """ + + def __init__(self, registrations: list[MarketplaceRegistration] | None = None): + """Initialize the registry with marketplace registrations. + + Args: + registrations: List of marketplace registrations. Can be empty or None. + """ + self._registrations: dict[str, MarketplaceRegistration] = {} + self._cache: dict[str, tuple[Marketplace, Path]] = {} # name -> (marketplace, path) + + if registrations: + for reg in registrations: + self._registrations[reg.name] = reg + + @property + def registrations(self) -> dict[str, MarketplaceRegistration]: + """Get all registered marketplaces.""" + return self._registrations.copy() + + def get_auto_load_registrations(self) -> list[MarketplaceRegistration]: + """Get registrations with auto_load='all'.""" + return [ + reg for reg in self._registrations.values() + if reg.auto_load == "all" + ] + + def _fetch_marketplace(self, reg: MarketplaceRegistration) -> tuple[Marketplace, Path]: + """Fetch a marketplace and return (Marketplace, repo_path). + + This is the internal method that does the actual fetching. + Results are cached to avoid repeated fetches. + """ + if reg.name in self._cache: + return self._cache[reg.name] + + logger.info(f"Fetching marketplace '{reg.name}' from {reg.source}") + + # Fetch the marketplace repository + repo_path, resolved_ref = fetch_plugin_with_resolution( + source=reg.source, + ref=reg.ref, + repo_path=reg.repo_path, + ) + + # Load the marketplace manifest + marketplace = Marketplace.load(repo_path) + + logger.debug( + f"Loaded marketplace '{reg.name}' with {len(marketplace.plugins)} plugins" + + (f" @ {resolved_ref[:8]}" if resolved_ref else "") + ) + + # Cache the result + self._cache[reg.name] = (marketplace, Path(repo_path)) + return marketplace, Path(repo_path) + + def get_marketplace(self, name: str) -> tuple[Marketplace, Path]: + """Get a marketplace by name, fetching lazily if needed. + + Args: + name: The marketplace registration name. + + Returns: + Tuple of (Marketplace, repo_path). + + Raises: + MarketplaceNotFoundError: If the marketplace is not registered. + """ + if name not in self._registrations: + raise MarketplaceNotFoundError(name) + + return self._fetch_marketplace(self._registrations[name]) + + def prefetch_all(self) -> None: + """Eagerly fetch all registered marketplaces. + + This is useful for validation or pre-warming the cache. + Any fetch errors are logged but not raised. + """ + for name, reg in self._registrations.items(): + try: + self._fetch_marketplace(reg) + except Exception as e: + logger.warning(f"Failed to prefetch marketplace '{name}': {e}") + + def _parse_plugin_ref(self, plugin_ref: str) -> tuple[str, str | None]: + """Parse a plugin reference into (plugin_name, marketplace_name). + + Formats: + - 'plugin-name' -> ('plugin-name', None) + - 'plugin-name@marketplace' -> ('plugin-name', 'marketplace') + """ + if "@" in plugin_ref: + parts = plugin_ref.rsplit("@", 1) + return parts[0], parts[1] + return plugin_ref, None + + def resolve_plugin(self, plugin_ref: str) -> PluginSource: + """Resolve a plugin reference to a PluginSource. + + Args: + plugin_ref: Plugin reference in format 'plugin-name' or + 'plugin-name@marketplace-name'. + + Returns: + PluginSource that can be used to load the plugin. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + """ + plugin_name, marketplace_name = self._parse_plugin_ref(plugin_ref) + + if marketplace_name: + # Explicit marketplace specified + return self._resolve_from_marketplace(plugin_name, marketplace_name) + else: + # Search all registered marketplaces + return self._resolve_from_all(plugin_name) + + def _resolve_from_marketplace( + self, plugin_name: str, marketplace_name: str + ) -> PluginSource: + """Resolve a plugin from a specific marketplace.""" + marketplace, repo_path = self.get_marketplace(marketplace_name) + + plugin_entry = marketplace.get_plugin(plugin_name) + if plugin_entry is None: + raise PluginNotFoundError(plugin_name, marketplace_name) + + # Resolve the plugin source + source, ref, subpath = marketplace.resolve_plugin_source(plugin_entry) + + return PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + + def _resolve_from_all(self, plugin_name: str) -> PluginSource: + """Resolve a plugin by searching all registered marketplaces.""" + matches: list[tuple[str, PluginSource]] = [] + + for name, reg in self._registrations.items(): + try: + marketplace, repo_path = self._fetch_marketplace(reg) + plugin_entry = marketplace.get_plugin(plugin_name) + + if plugin_entry is not None: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + plugin_source = PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + matches.append((name, plugin_source)) + + except Exception as e: + logger.warning( + f"Error searching marketplace '{name}' for plugin '{plugin_name}': {e}" + ) + + if not matches: + raise PluginNotFoundError(plugin_name) + + if len(matches) > 1: + raise AmbiguousPluginError( + plugin_name, + [name for name, _ in matches], + ) + + return matches[0][1] + + def list_plugins(self, marketplace_name: str | None = None) -> list[str]: + """List available plugins from registered marketplaces. + + Args: + marketplace_name: If provided, list plugins from this marketplace only. + If None, list plugins from all registered marketplaces. + + Returns: + List of plugin names (may include duplicates if searching all). + """ + plugin_names: list[str] = [] + + if marketplace_name: + marketplace, _ = self.get_marketplace(marketplace_name) + plugin_names.extend(p.name for p in marketplace.plugins) + else: + for name, reg in self._registrations.items(): + try: + marketplace, _ = self._fetch_marketplace(reg) + plugin_names.extend(p.name for p in marketplace.plugins) + except Exception as e: + logger.warning(f"Error listing plugins from '{name}': {e}") + + return plugin_names diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index b6eb3adf69..478d0ff46d 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import frontmatter from pydantic import BaseModel, Field, field_validator, model_validator @@ -15,6 +15,80 @@ MARKETPLACE_MANIFEST_FILE = "marketplace.json" +class MarketplaceRegistration(BaseModel): + """Registration for a plugin marketplace. + + Represents a marketplace that can be registered for plugin resolution. + Marketplaces can be auto-loaded (plugins loaded at conversation start) + or registered only (available for explicit plugin references). + + Examples: + >>> # Auto-load all plugins from a marketplace + >>> MarketplaceRegistration( + ... name="public", + ... source="github:OpenHands/skills", + ... auto_load="all" + ... ) + + >>> # Register marketplace without auto-loading + >>> MarketplaceRegistration( + ... name="experimental", + ... source="github:acme/experimental" + ... ) + + >>> # Marketplace in monorepo subdirectory + >>> MarketplaceRegistration( + ... name="team", + ... source="github:acme/monorepo", + ... repo_path="marketplaces/internal", + ... auto_load="all" + ... ) + """ + + name: str = Field( + description="Identifier for this marketplace registration" + ) + source: str = Field( + description="Marketplace source: 'github:owner/repo', git URL, or local path" + ) + ref: str | None = Field( + default=None, + description="Optional branch, tag, or commit (only for git sources)", + ) + repo_path: str | None = Field( + default=None, + description=( + "Subdirectory path within the git repository containing the marketplace " + "(e.g., 'marketplaces/internal' for monorepos). " + "Only relevant for git sources, not local paths." + ), + ) + auto_load: Literal["all"] | None = Field( + default=None, + description=( + "Auto-load behavior for this marketplace. " + "'all' = load all plugins at conversation start. " + "None = registered for resolution but not auto-loaded." + ), + ) + + @field_validator("repo_path") + @classmethod + def validate_repo_path(cls, v: str | None) -> str | None: + """Validate repo_path is a safe relative path within the repository.""" + if v is None: + return v + # Must be relative (no absolute paths) + if v.startswith("/"): + raise ValueError("repo_path must be relative, not absolute") + # No parent directory traversal + if ".." in Path(v).parts: + raise ValueError( + "repo_path cannot contain '..' (parent directory traversal)" + ) + return v + + class PluginSource(BaseModel): """Specification for a plugin to load. diff --git a/tests/sdk/plugin/test_marketplace_registry.py b/tests/sdk/plugin/test_marketplace_registry.py new file mode 100644 index 0000000000..c6d439c8cd --- /dev/null +++ b/tests/sdk/plugin/test_marketplace_registry.py @@ -0,0 +1,334 @@ +"""Tests for MarketplaceRegistry and MarketplaceRegistration.""" + +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from openhands.sdk.plugin import ( + MarketplaceRegistration, + MarketplaceRegistry, + PluginSource, + PluginNotFoundError, + AmbiguousPluginError, + MarketplaceNotFoundError, + Marketplace, + MarketplaceOwner, + MarketplacePluginEntry, +) + + +class TestMarketplaceRegistration: + """Tests for MarketplaceRegistration model.""" + + def test_basic_registration(self): + """Test creating a basic marketplace registration.""" + reg = MarketplaceRegistration( + name="test-marketplace", + source="github:owner/repo", + ) + assert reg.name == "test-marketplace" + assert reg.source == "github:owner/repo" + assert reg.ref is None + assert reg.repo_path is None + assert reg.auto_load is None + + def test_registration_with_auto_load(self): + """Test registration with auto_load='all'.""" + reg = MarketplaceRegistration( + name="public", + source="github:OpenHands/skills", + auto_load="all", + ) + assert reg.auto_load == "all" + + def test_registration_with_ref(self): + """Test registration with specific ref.""" + reg = MarketplaceRegistration( + name="versioned", + source="github:owner/repo", + ref="v1.0.0", + ) + assert reg.ref == "v1.0.0" + + def test_registration_with_repo_path(self): + """Test registration with repo_path for monorepos.""" + reg = MarketplaceRegistration( + name="monorepo-marketplace", + source="github:acme/monorepo", + repo_path="marketplaces/internal", + ) + assert reg.repo_path == "marketplaces/internal" + + def test_repo_path_validation_rejects_absolute(self): + """Test that absolute repo_path is rejected.""" + with pytest.raises(ValueError, match="must be relative"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="/absolute/path", + ) + + def test_repo_path_validation_rejects_traversal(self): + """Test that parent directory traversal is rejected.""" + with pytest.raises(ValueError, match="cannot contain '..'"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="../escape/path", + ) + + +class TestMarketplaceRegistry: + """Tests for MarketplaceRegistry.""" + + def test_empty_registry(self): + """Test creating an empty registry.""" + registry = MarketplaceRegistry() + assert registry.registrations == {} + assert registry.get_auto_load_registrations() == [] + + def test_registry_with_registrations(self): + """Test creating a registry with registrations.""" + regs = [ + MarketplaceRegistration(name="a", source="github:owner/a", auto_load="all"), + MarketplaceRegistration(name="b", source="github:owner/b"), + ] + registry = MarketplaceRegistry(regs) + + assert len(registry.registrations) == 2 + assert "a" in registry.registrations + assert "b" in registry.registrations + + def test_get_auto_load_registrations(self): + """Test filtering auto-load registrations.""" + regs = [ + MarketplaceRegistration(name="a", source="github:owner/a", auto_load="all"), + MarketplaceRegistration(name="b", source="github:owner/b"), + MarketplaceRegistration(name="c", source="github:owner/c", auto_load="all"), + ] + registry = MarketplaceRegistry(regs) + + auto_load = registry.get_auto_load_registrations() + assert len(auto_load) == 2 + assert all(r.auto_load == "all" for r in auto_load) + + def test_marketplace_not_found_error(self): + """Test error when marketplace not registered.""" + registry = MarketplaceRegistry() + + with pytest.raises(MarketplaceNotFoundError) as exc_info: + registry.get_marketplace("nonexistent") + + assert exc_info.value.marketplace_name == "nonexistent" + + def test_parse_plugin_ref_simple(self): + """Test parsing simple plugin reference.""" + registry = MarketplaceRegistry() + name, marketplace = registry._parse_plugin_ref("my-plugin") + assert name == "my-plugin" + assert marketplace is None + + def test_parse_plugin_ref_with_marketplace(self): + """Test parsing plugin reference with marketplace.""" + registry = MarketplaceRegistry() + name, marketplace = registry._parse_plugin_ref("my-plugin@team-tools") + assert name == "my-plugin" + assert marketplace == "team-tools" + + def test_parse_plugin_ref_with_at_in_name(self): + """Test parsing plugin reference with @ in plugin name.""" + registry = MarketplaceRegistry() + # If plugin name has @, last @ is the delimiter + name, marketplace = registry._parse_plugin_ref("plugin@1.0@marketplace") + assert name == "plugin@1.0" + assert marketplace == "marketplace" + + +@pytest.fixture +def mock_marketplace_dir(tmp_path): + """Create a mock marketplace directory structure.""" + # Create .plugin/marketplace.json + plugin_dir = tmp_path / ".plugin" + plugin_dir.mkdir() + + marketplace_data = { + "name": "test-marketplace", + "owner": {"name": "Test Owner"}, + "plugins": [ + {"name": "plugin-a", "source": "./plugins/a", "description": "Plugin A"}, + {"name": "plugin-b", "source": "./plugins/b", "description": "Plugin B"}, + ], + "skills": [], + } + + (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Create plugin directories + (tmp_path / "plugins" / "a").mkdir(parents=True) + (tmp_path / "plugins" / "b").mkdir(parents=True) + + return tmp_path + + +class TestMarketplaceRegistryResolution: + """Tests for plugin resolution in MarketplaceRegistry.""" + + def test_resolve_plugin_from_specific_marketplace(self, mock_marketplace_dir): + """Test resolving a plugin from a specific marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + # Mock fetch to return the local path + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + source = registry.resolve_plugin("plugin-a@test") + + assert source.source == str(mock_marketplace_dir / "plugins" / "a") + + def test_resolve_plugin_not_found_in_marketplace(self, mock_marketplace_dir): + """Test error when plugin not found in specified marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent@test") + + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name == "test" + + def test_resolve_plugin_marketplace_not_registered(self): + """Test error when referenced marketplace is not registered.""" + registry = MarketplaceRegistry() + + with pytest.raises(MarketplaceNotFoundError) as exc_info: + registry.resolve_plugin("plugin@unknown") + + assert exc_info.value.marketplace_name == "unknown" + + def test_resolve_plugin_search_all_marketplaces(self, mock_marketplace_dir): + """Test resolving a plugin by searching all marketplaces.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + source = registry.resolve_plugin("plugin-a") + + assert source.source == str(mock_marketplace_dir / "plugins" / "a") + + def test_resolve_plugin_not_found_anywhere(self, mock_marketplace_dir): + """Test error when plugin not found in any marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent") + + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name is None + + def test_resolve_plugin_ambiguous(self, tmp_path): + """Test error when plugin found in multiple marketplaces.""" + # Create two marketplace directories with same plugin name + for name in ["marketplace1", "marketplace2"]: + mp_dir = tmp_path / name + mp_dir.mkdir() + plugin_dir = mp_dir / ".plugin" + plugin_dir.mkdir() + + marketplace_data = { + "name": name, + "owner": {"name": "Owner"}, + "plugins": [ + {"name": "common-plugin", "source": "./plugins/common"}, + ], + } + (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + (mp_dir / "plugins" / "common").mkdir(parents=True) + + regs = [ + MarketplaceRegistration(name="mp1", source=str(tmp_path / "marketplace1")), + MarketplaceRegistration(name="mp2", source=str(tmp_path / "marketplace2")), + ] + registry = MarketplaceRegistry(regs) + + def mock_fetch(reg): + mp_path = tmp_path / ("marketplace1" if reg.name == "mp1" else "marketplace2") + marketplace = Marketplace.load(mp_path) + return (marketplace, mp_path) + + with patch.object(registry, '_fetch_marketplace', side_effect=mock_fetch): + with pytest.raises(AmbiguousPluginError) as exc_info: + registry.resolve_plugin("common-plugin") + + assert exc_info.value.plugin_name == "common-plugin" + assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} + + def test_list_plugins_from_marketplace(self, mock_marketplace_dir): + """Test listing plugins from a specific marketplace.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + plugins = registry.list_plugins("test") + + assert set(plugins) == {"plugin-a", "plugin-b"} + + def test_list_plugins_from_all(self, mock_marketplace_dir): + """Test listing plugins from all marketplaces.""" + reg = MarketplaceRegistration( + name="test", + source=str(mock_marketplace_dir), + ) + registry = MarketplaceRegistry([reg]) + + with patch.object( + registry, '_fetch_marketplace' + ) as mock_fetch: + marketplace = Marketplace.load(mock_marketplace_dir) + mock_fetch.return_value = (marketplace, mock_marketplace_dir) + + plugins = registry.list_plugins() + + assert set(plugins) == {"plugin-a", "plugin-b"} From 084e527e6fb8cfd74c724a8a4c0d0a6accf2c20f Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 19:09:18 +0000 Subject: [PATCH 02/20] test(plugin): Add integration tests for marketplace registration and plugin resolution flow Adds comprehensive integration tests that demonstrate: - Single and multiple marketplace registration - Auto-load vs registered-only marketplaces - Plugin resolution with explicit and implicit marketplace references - Ambiguous plugin name handling - Plugin not found error scenarios - Marketplace caching behavior - Prefetch functionality - Monorepo subdirectory support - Full plugin load flow from registration to Plugin.load() - Claude Code .claude-plugin directory fallback Co-authored-by: openhands --- .../test_marketplace_registry_integration.py | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 tests/sdk/plugin/test_marketplace_registry_integration.py diff --git a/tests/sdk/plugin/test_marketplace_registry_integration.py b/tests/sdk/plugin/test_marketplace_registry_integration.py new file mode 100644 index 0000000000..1b6ee173da --- /dev/null +++ b/tests/sdk/plugin/test_marketplace_registry_integration.py @@ -0,0 +1,471 @@ +"""Integration tests for MarketplaceRegistry - full registration and resolution flow.""" + +import json +import pytest +from pathlib import Path + +from openhands.sdk.plugin import ( + MarketplaceRegistration, + MarketplaceRegistry, + PluginSource, + Plugin, + Marketplace, + PluginNotFoundError, + AmbiguousPluginError, +) + + +def create_marketplace( + base_path: Path, + name: str, + plugins: list[dict], + skills: list[dict] | None = None, +) -> Path: + """Helper to create a complete marketplace directory structure. + + Args: + base_path: Parent directory for the marketplace + name: Marketplace name + plugins: List of plugin definitions, each with 'name' and optionally 'description' + skills: Optional list of skill definitions + + Returns: + Path to the marketplace directory + """ + marketplace_dir = base_path / name + marketplace_dir.mkdir(parents=True, exist_ok=True) + + # Create .plugin/marketplace.json + plugin_meta_dir = marketplace_dir / ".plugin" + plugin_meta_dir.mkdir(exist_ok=True) + + # Build plugin entries with sources pointing to local directories + plugin_entries = [] + for plugin in plugins: + plugin_name = plugin["name"] + plugin_entries.append({ + "name": plugin_name, + "source": f"./plugins/{plugin_name}", + "description": plugin.get("description", f"Plugin {plugin_name}"), + }) + + # Create plugin directory with plugin.json + plugin_dir = marketplace_dir / "plugins" / plugin_name / ".plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + + plugin_manifest = { + "name": plugin_name, + "version": plugin.get("version", "1.0.0"), + "description": plugin.get("description", f"Plugin {plugin_name}"), + } + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest, indent=2)) + + # Create a sample skill in the plugin + skills_dir = plugin_dir / "skills" + skills_dir.mkdir(exist_ok=True) + skill_content = f"""--- +name: {plugin_name}-skill +description: A skill from {plugin_name} +--- + +# {plugin_name} Skill + +This is a skill provided by the {plugin_name} plugin. +""" + (skills_dir / "SKILL.md").write_text(skill_content) + + marketplace_data = { + "name": name, + "owner": {"name": "Test Owner", "email": "test@example.com"}, + "description": f"Test marketplace: {name}", + "plugins": plugin_entries, + "skills": skills or [], + } + + (plugin_meta_dir / "marketplace.json").write_text( + json.dumps(marketplace_data, indent=2) + ) + + return marketplace_dir + + +class TestMarketplaceRegistryIntegration: + """Integration tests for the full marketplace registration and resolution flow.""" + + def test_single_marketplace_registration_and_resolution(self, tmp_path): + """Test registering a single marketplace and resolving plugins from it.""" + # Create a marketplace with two plugins + marketplace_dir = create_marketplace( + tmp_path, + name="company-tools", + plugins=[ + {"name": "formatter", "description": "Code formatter"}, + {"name": "linter", "description": "Code linter"}, + ], + ) + + # Register the marketplace + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="company", + source=str(marketplace_dir), + auto_load="all", + ), + ]) + + # Verify registration + assert "company" in registry.registrations + assert registry.registrations["company"].auto_load == "all" + + # Resolve plugin with explicit marketplace + source = registry.resolve_plugin("formatter@company") + assert source.source == str(marketplace_dir / "plugins" / "formatter") + + # Resolve plugin without marketplace (search all) + source = registry.resolve_plugin("linter") + assert source.source == str(marketplace_dir / "plugins" / "linter") + + # List all plugins + plugins = registry.list_plugins("company") + assert set(plugins) == {"formatter", "linter"} + + def test_multiple_marketplace_registration(self, tmp_path): + """Test registering multiple marketplaces with different plugins.""" + # Create two marketplaces + public_dir = create_marketplace( + tmp_path, + name="public-marketplace", + plugins=[ + {"name": "git", "description": "Git utilities"}, + {"name": "docker", "description": "Docker utilities"}, + ], + ) + + team_dir = create_marketplace( + tmp_path, + name="team-marketplace", + plugins=[ + {"name": "deploy", "description": "Deployment tools"}, + {"name": "monitor", "description": "Monitoring tools"}, + ], + ) + + # Register both marketplaces + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", + ), + MarketplaceRegistration( + name="team", + source=str(team_dir), + auto_load="all", + ), + ]) + + # Resolve plugins from specific marketplaces + git_source = registry.resolve_plugin("git@public") + assert "public-marketplace" in git_source.source + + deploy_source = registry.resolve_plugin("deploy@team") + assert "team-marketplace" in deploy_source.source + + # Resolve unique plugin without marketplace qualifier + docker_source = registry.resolve_plugin("docker") + assert "docker" in docker_source.source + + # List all plugins from all marketplaces + all_plugins = registry.list_plugins() + assert set(all_plugins) == {"git", "docker", "deploy", "monitor"} + + def test_auto_load_vs_registered_only(self, tmp_path): + """Test that auto_load setting is correctly tracked.""" + public_dir = create_marketplace( + tmp_path, + name="public", + plugins=[{"name": "common"}], + ) + + experimental_dir = create_marketplace( + tmp_path, + name="experimental", + plugins=[{"name": "beta-tool"}], + ) + + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", # Auto-load + ), + MarketplaceRegistration( + name="experimental", + source=str(experimental_dir), + # auto_load=None (default) - registered but not auto-loaded + ), + ]) + + # Check auto_load registrations + auto_load_regs = registry.get_auto_load_registrations() + assert len(auto_load_regs) == 1 + assert auto_load_regs[0].name == "public" + + # Both marketplaces can still resolve plugins + common_source = registry.resolve_plugin("common@public") + assert common_source is not None + + beta_source = registry.resolve_plugin("beta-tool@experimental") + assert beta_source is not None + + def test_ambiguous_plugin_error(self, tmp_path): + """Test that ambiguous plugin names raise appropriate error.""" + # Create two marketplaces with a plugin of the same name + mp1_dir = create_marketplace( + tmp_path, + name="marketplace1", + plugins=[{"name": "shared-plugin", "description": "Version from MP1"}], + ) + + mp2_dir = create_marketplace( + tmp_path, + name="marketplace2", + plugins=[{"name": "shared-plugin", "description": "Version from MP2"}], + ) + + registry = MarketplaceRegistry([ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ]) + + # Resolving without qualifier should fail + with pytest.raises(AmbiguousPluginError) as exc_info: + registry.resolve_plugin("shared-plugin") + + assert exc_info.value.plugin_name == "shared-plugin" + assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} + + # But explicit qualification should work + source1 = registry.resolve_plugin("shared-plugin@mp1") + assert "marketplace1" in source1.source + + source2 = registry.resolve_plugin("shared-plugin@mp2") + assert "marketplace2" in source2.source + + def test_plugin_not_found_error(self, tmp_path): + """Test that missing plugins raise appropriate error.""" + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "existing-plugin"}], + ) + + registry = MarketplaceRegistry([ + MarketplaceRegistration(name="test", source=str(marketplace_dir)), + ]) + + # Non-existent plugin in specific marketplace + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent@test") + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name == "test" + + # Non-existent plugin searching all + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent") + assert exc_info.value.plugin_name == "nonexistent" + assert exc_info.value.marketplace_name is None + + def test_marketplace_caching(self, tmp_path): + """Test that marketplace fetching is cached.""" + marketplace_dir = create_marketplace( + tmp_path, + name="cached-marketplace", + plugins=[{"name": "plugin-a"}, {"name": "plugin-b"}], + ) + + registry = MarketplaceRegistry([ + MarketplaceRegistration(name="cached", source=str(marketplace_dir)), + ]) + + # First resolution - should fetch and cache + source_a = registry.resolve_plugin("plugin-a@cached") + + # Check that marketplace is now cached + assert "cached" in registry._cache + + # Second resolution - should use cache + source_b = registry.resolve_plugin("plugin-b@cached") + + # Cache should still have just one entry + assert len(registry._cache) == 1 + + def test_prefetch_all_marketplaces(self, tmp_path): + """Test eagerly prefetching all registered marketplaces.""" + mp1_dir = create_marketplace(tmp_path, "mp1", [{"name": "p1"}]) + mp2_dir = create_marketplace(tmp_path, "mp2", [{"name": "p2"}]) + + registry = MarketplaceRegistry([ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ]) + + # Cache should be empty initially + assert len(registry._cache) == 0 + + # Prefetch all + registry.prefetch_all() + + # Both should now be cached + assert len(registry._cache) == 2 + assert "mp1" in registry._cache + assert "mp2" in registry._cache + + def test_monorepo_marketplace_with_repo_path(self, tmp_path): + """Test marketplace in a monorepo subdirectory.""" + # Create a monorepo structure + monorepo_dir = tmp_path / "monorepo" + monorepo_dir.mkdir() + + # Create marketplace in a subdirectory + marketplace_subdir = monorepo_dir / "packages" / "marketplace" + marketplace_subdir.mkdir(parents=True) + + # Create .plugin structure in the subdirectory + plugin_meta_dir = marketplace_subdir / ".plugin" + plugin_meta_dir.mkdir() + + # Create plugin directory + plugin_dir = marketplace_subdir / "plugins" / "monorepo-plugin" / ".plugin" + plugin_dir.mkdir(parents=True) + + plugin_manifest = {"name": "monorepo-plugin", "version": "1.0.0"} + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) + + marketplace_data = { + "name": "monorepo-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "monorepo-plugin", "source": "./plugins/monorepo-plugin"}, + ], + } + (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Register with repo_path pointing to the subdirectory + # Note: For local paths, repo_path isn't used the same way as git repos + # The source should point directly to the marketplace directory + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="monorepo", + source=str(marketplace_subdir), + ), + ]) + + # Should be able to resolve the plugin + source = registry.resolve_plugin("monorepo-plugin@monorepo") + assert "monorepo-plugin" in source.source + + def test_full_plugin_load_flow(self, tmp_path): + """Test the complete flow from registration to plugin loading.""" + # Create a marketplace directory manually with proper plugin structure + marketplace_dir = tmp_path / "full-test-marketplace" + marketplace_dir.mkdir() + + # Create marketplace manifest + mp_meta_dir = marketplace_dir / ".plugin" + mp_meta_dir.mkdir() + + marketplace_data = { + "name": "full-test-marketplace", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "test-plugin", "source": "./plugins/test-plugin"}, + ], + } + (mp_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Create plugin with skills directory at root level (not inside .plugin) + plugin_dir = marketplace_dir / "plugins" / "test-plugin" + plugin_dir.mkdir(parents=True) + + # Plugin manifest in .plugin/ + plugin_meta_dir = plugin_dir / ".plugin" + plugin_meta_dir.mkdir() + (plugin_meta_dir / "plugin.json").write_text(json.dumps({ + "name": "test-plugin", + "version": "1.0.0", + "description": "A complete test plugin", + })) + + # Skills directory at plugin root level + skills_dir = plugin_dir / "skills" + skills_dir.mkdir() + skill_content = """--- +name: test-plugin-skill +description: A skill from test-plugin +--- + +# Test Plugin Skill + +This is a skill provided by the test-plugin plugin. +""" + (skills_dir / "SKILL.md").write_text(skill_content) + + # Register the marketplace + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="fulltest", + source=str(marketplace_dir), + auto_load="all", + ), + ]) + + # Resolve the plugin + plugin_source = registry.resolve_plugin("test-plugin@fulltest") + + # Load the plugin using the resolved source + plugin = Plugin.load(plugin_source.source) + + # Verify plugin was loaded correctly + assert plugin.manifest.name == "test-plugin" + assert plugin.manifest.description == "A complete test plugin" + + # Check that skills were loaded + assert len(plugin.skills) > 0 + assert any("test-plugin" in s.name for s in plugin.skills) + + def test_marketplace_with_claude_plugin_directory(self, tmp_path): + """Test marketplace using .claude-plugin directory (fallback).""" + marketplace_dir = tmp_path / "claude-compat-marketplace" + marketplace_dir.mkdir() + + # Use .claude-plugin instead of .plugin + plugin_meta_dir = marketplace_dir / ".claude-plugin" + plugin_meta_dir.mkdir() + + # Create plugin + plugin_dir = marketplace_dir / "plugins" / "claude-plugin" / ".claude-plugin" + plugin_dir.mkdir(parents=True) + + plugin_manifest = {"name": "claude-plugin", "version": "1.0.0"} + (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) + + marketplace_data = { + "name": "claude-compat", + "owner": {"name": "Test"}, + "plugins": [ + {"name": "claude-plugin", "source": "./plugins/claude-plugin"}, + ], + } + (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) + + # Register and resolve + registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="claude", + source=str(marketplace_dir), + ), + ]) + + source = registry.resolve_plugin("claude-plugin@claude") + assert "claude-plugin" in source.source From 5a622537f7e9d1ab0911693dbd144a9de3cd6c25 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 19:19:08 +0000 Subject: [PATCH 03/20] docs(examples): Add multiple marketplace registrations example Demonstrates the full marketplace registration and plugin resolution flow: - Registering multiple marketplaces with different auto-load settings - Resolving plugins with explicit and implicit marketplace references - Loading plugins from resolved sources - Error handling (not found, ambiguous, unregistered) - Prefetching for validation - Integration pattern with AgentContext Co-authored-by: openhands --- .../README.md | 80 ++++ .../main.py | 352 ++++++++++++++++++ .../company-tools/.plugin/marketplace.json | 16 + .../plugins/formatter/.plugin/plugin.json | 5 + .../plugins/formatter/skills/SKILL.md | 8 + .../experimental/.plugin/marketplace.json | 15 + .../plugins/beta-tool/.plugin/plugin.json | 5 + .../plugins/beta-tool/skills/SKILL.md | 8 + 8 files changed, 489 insertions(+) create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md new file mode 100644 index 0000000000..709666cd57 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md @@ -0,0 +1,80 @@ +# Multiple Marketplace Registrations + +This example demonstrates how to register multiple plugin marketplaces with different auto-load behaviors using `MarketplaceRegistration` and `MarketplaceRegistry`. + +## Key Concepts + +### MarketplaceRegistration + +Declares a marketplace with its source and auto-load setting: + +```python +MarketplaceRegistration( + name="company", # Identifier for this registration + source="github:mycompany/plugins", # Source: GitHub, git URL, or local path + ref="v2.0.0", # Optional: branch, tag, or commit + repo_path="internal/marketplace", # Optional: subdirectory for monorepos + auto_load="all", # "all" or None +) +``` + +### auto_load Behavior + +| Setting | Behavior | +|---------|----------| +| `auto_load="all"` | Load all plugins from this marketplace at conversation start | +| `auto_load=None` (default) | Register for resolution but don't auto-load plugins | + +### Plugin Resolution + +```python +# Explicit marketplace qualifier +source = registry.resolve_plugin("formatter@company") + +# Search all registered marketplaces (errors if ambiguous) +source = registry.resolve_plugin("formatter") +``` + +## Use Cases + +1. **Enterprise teams** - Internal marketplace + curated public plugins +2. **Multi-team organizations** - Team-specific + shared company marketplaces +3. **Experimental plugins** - Register but don't auto-load until tested + +## Running the Example + +```bash +cd examples/05_skills_and_plugins/04_multiple_marketplace_registrations +python main.py +``` + +## Output + +The example demonstrates: +1. Registering multiple marketplaces with different auto-load settings +2. Resolving plugins with explicit and implicit marketplace references +3. Loading plugins from resolved sources +4. Error handling for not found, ambiguous, and unregistered cases +5. Prefetching marketplaces for validation +6. Integration pattern with AgentContext + +## Directory Structure + +``` +04_multiple_marketplace_registrations/ +├── marketplaces/ # Created by the example +│ ├── company-tools/ +│ │ └── .plugin/marketplace.json +│ │ └── plugins/formatter/... +│ └── experimental/ +│ └── .plugin/marketplace.json +│ └── plugins/beta-tool/... +├── main.py # Example code +└── README.md # This file +``` + +## Related + +- [02_loading_plugins](../02_loading_plugins/) - Loading individual plugins +- [43_mixed_marketplace_skills](../../01_standalone_sdk/43_mixed_marketplace_skills/) - Single marketplace with mixed sources +- [Plugin documentation](https://docs.all-hands.dev/sdk/guides/plugins) diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py new file mode 100644 index 0000000000..8f943c2f80 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -0,0 +1,352 @@ +"""Example: Multiple Marketplace Registrations + +This example demonstrates how to register multiple plugin marketplaces +with different auto-load behaviors using MarketplaceRegistration and +MarketplaceRegistry. + +Key concepts: +1. MarketplaceRegistration - Declares a marketplace with source and auto-load setting +2. MarketplaceRegistry - Manages registered marketplaces with lazy fetching and resolution +3. auto_load="all" - Load all plugins at conversation start +4. auto_load=None - Register for resolution but don't auto-load + +Use cases: +- Enterprise teams with internal + public marketplaces +- Curated plugin sets from multiple sources +- Experimental plugins registered but not auto-loaded + +Plugin resolution: +- "plugin-name@marketplace" - Explicit marketplace reference +- "plugin-name" - Search all registered marketplaces (errors if ambiguous) + +Directory Structure: + 04_multiple_marketplace_registrations/ + ├── marketplaces/ + │ ├── company-tools/ # Internal company marketplace + │ │ └── .plugin/ + │ │ └── marketplace.json + │ │ └── plugins/ + │ │ └── formatter/ + │ │ └── .plugin/ + │ │ └── plugin.json + │ │ └── skills/ + │ │ └── SKILL.md + │ └── experimental/ # Experimental marketplace + │ └── .plugin/ + │ └── marketplace.json + │ └── plugins/ + │ └── beta-tool/ + │ └── .plugin/ + │ └── plugin.json + │ └── skills/ + │ └── SKILL.md + └── main.py +""" + +import json +from pathlib import Path + +from openhands.sdk.plugin import ( + MarketplaceRegistration, + MarketplaceRegistry, + Plugin, + PluginNotFoundError, + AmbiguousPluginError, +) + + +script_dir = Path(__file__).parent + + +def setup_example_marketplaces() -> tuple[Path, Path]: + """Create example marketplace directories for this demo. + + In production, these would be: + - GitHub repos: github:company/internal-tools + - Git URLs: https://gitlab.internal/team/plugins.git + - Local paths: /opt/company/marketplaces/approved + """ + marketplaces_dir = script_dir / "marketplaces" + + # Create company-tools marketplace + company_mp = marketplaces_dir / "company-tools" + company_meta = company_mp / ".plugin" + company_meta.mkdir(parents=True, exist_ok=True) + + company_manifest = { + "name": "company-tools", + "owner": {"name": "DevOps Team", "email": "devops@company.com"}, + "description": "Internal company plugins for development workflows", + "plugins": [ + { + "name": "formatter", + "source": "./plugins/formatter", + "description": "Company code formatter with internal style rules", + }, + ], + "skills": [], + } + (company_meta / "marketplace.json").write_text(json.dumps(company_manifest, indent=2)) + + # Create formatter plugin + formatter_dir = company_mp / "plugins" / "formatter" + formatter_meta = formatter_dir / ".plugin" + formatter_meta.mkdir(parents=True, exist_ok=True) + (formatter_meta / "plugin.json").write_text(json.dumps({ + "name": "formatter", + "version": "2.1.0", + "description": "Company code formatter with internal style rules", + }, indent=2)) + + formatter_skills = formatter_dir / "skills" + formatter_skills.mkdir(exist_ok=True) + (formatter_skills / "SKILL.md").write_text("""--- +name: formatter-skill +description: Code formatting with company standards +--- + +# Company Formatter Skill + +Apply company code style rules to your project. +""") + + # Create experimental marketplace + experimental_mp = marketplaces_dir / "experimental" + experimental_meta = experimental_mp / ".plugin" + experimental_meta.mkdir(parents=True, exist_ok=True) + + experimental_manifest = { + "name": "experimental", + "owner": {"name": "R&D Team"}, + "description": "Experimental plugins - use at your own risk", + "plugins": [ + { + "name": "beta-tool", + "source": "./plugins/beta-tool", + "description": "Experimental AI-assisted refactoring", + }, + ], + "skills": [], + } + (experimental_meta / "marketplace.json").write_text( + json.dumps(experimental_manifest, indent=2) + ) + + # Create beta-tool plugin + beta_dir = experimental_mp / "plugins" / "beta-tool" + beta_meta = beta_dir / ".plugin" + beta_meta.mkdir(parents=True, exist_ok=True) + (beta_meta / "plugin.json").write_text(json.dumps({ + "name": "beta-tool", + "version": "0.1.0-beta", + "description": "Experimental AI-assisted refactoring", + }, indent=2)) + + beta_skills = beta_dir / "skills" + beta_skills.mkdir(exist_ok=True) + (beta_skills / "SKILL.md").write_text("""--- +name: beta-tool-skill +description: AI-assisted refactoring (experimental) +--- + +# Beta Tool Skill + +Experimental AI-assisted code refactoring. Use with caution. +""") + + return company_mp, experimental_mp + + +def demo_registration(company_mp: Path, experimental_mp: Path) -> MarketplaceRegistry: + """Demo 1: Register multiple marketplaces with different auto-load settings.""" + print("\n" + "=" * 60) + print("DEMO 1: Registering Multiple Marketplaces") + print("=" * 60) + + registry = MarketplaceRegistry([ + # Company tools: auto-load all plugins + MarketplaceRegistration( + name="company", + source=str(company_mp), + auto_load="all", # Load at conversation start + ), + # Experimental: register but don't auto-load + MarketplaceRegistration( + name="experimental", + source=str(experimental_mp), + # auto_load=None (default) - available for resolution but not auto-loaded + ), + ]) + + print("\nRegistered marketplaces:") + for name, reg in registry.registrations.items(): + auto_load_status = "auto-load" if reg.auto_load == "all" else "on-demand" + print(f" - {name}: {reg.source} ({auto_load_status})") + + auto_load_regs = registry.get_auto_load_registrations() + print(f"\nMarketplaces with auto_load='all': {[r.name for r in auto_load_regs]}") + + return registry + + +def demo_plugin_resolution(registry: MarketplaceRegistry) -> None: + """Demo 2: Resolve plugins from registered marketplaces.""" + print("\n" + "=" * 60) + print("DEMO 2: Resolving Plugins") + print("=" * 60) + + # Resolve with explicit marketplace qualifier + print("\n1. Explicit marketplace qualifier:") + source = registry.resolve_plugin("formatter@company") + print(f" 'formatter@company' -> {source.source}") + + source = registry.resolve_plugin("beta-tool@experimental") + print(f" 'beta-tool@experimental' -> {source.source}") + + # Resolve without qualifier (unique name) + print("\n2. Search all marketplaces (unique names):") + source = registry.resolve_plugin("formatter") + print(f" 'formatter' -> {source.source}") + + # List plugins from specific marketplace + print("\n3. List plugins from 'company' marketplace:") + plugins = registry.list_plugins("company") + for name in plugins: + print(f" - {name}") + + # List all plugins across all marketplaces + print("\n4. List plugins from all marketplaces:") + all_plugins = registry.list_plugins() + for name in all_plugins: + print(f" - {name}") + + +def demo_plugin_loading(registry: MarketplaceRegistry) -> None: + """Demo 3: Load plugins using resolved sources.""" + print("\n" + "=" * 60) + print("DEMO 3: Loading Plugins from Resolved Sources") + print("=" * 60) + + # Resolve and load a plugin + source = registry.resolve_plugin("formatter@company") + plugin = Plugin.load(source.source) + + print(f"\nLoaded plugin: {plugin.manifest.name}") + print(f" Version: {plugin.manifest.version}") + print(f" Description: {plugin.manifest.description}") + print(f" Skills: {len(plugin.skills)}") + for skill in plugin.skills: + print(f" - {skill.name}: {skill.description}") + + +def demo_error_handling(registry: MarketplaceRegistry, experimental_mp: Path) -> None: + """Demo 4: Error handling for resolution failures.""" + print("\n" + "=" * 60) + print("DEMO 4: Error Handling") + print("=" * 60) + + # Plugin not found + print("\n1. Plugin not found:") + try: + registry.resolve_plugin("nonexistent@company") + except PluginNotFoundError as e: + print(f" PluginNotFoundError: {e}") + + # Marketplace not registered + print("\n2. Marketplace not registered:") + try: + registry.resolve_plugin("some-plugin@unknown") + except Exception as e: + print(f" {type(e).__name__}: {e}") + + # Ambiguous plugin (add duplicate to demonstrate) + print("\n3. Ambiguous plugin name (simulated):") + # Create a temporary registry with duplicate plugin names + temp_registry = MarketplaceRegistry([ + MarketplaceRegistration(name="mp1", source=str(experimental_mp)), + MarketplaceRegistration(name="mp2", source=str(experimental_mp)), + ]) + try: + temp_registry.resolve_plugin("beta-tool") + except AmbiguousPluginError as e: + print(f" AmbiguousPluginError: {e}") + + +def demo_prefetch(registry: MarketplaceRegistry) -> None: + """Demo 5: Eager prefetching for validation.""" + print("\n" + "=" * 60) + print("DEMO 5: Prefetching Marketplaces") + print("=" * 60) + + print(f"\nCache before prefetch: {len(registry._cache)} entries") + + # Prefetch all registered marketplaces + registry.prefetch_all() + + print(f"Cache after prefetch: {len(registry._cache)} entries") + print("Cached marketplaces:") + for name in registry._cache: + marketplace, path = registry._cache[name] + print(f" - {name}: {len(marketplace.plugins)} plugins") + + +def demo_conversation_integration() -> None: + """Demo 6: How this integrates with AgentContext (conceptual).""" + print("\n" + "=" * 60) + print("DEMO 6: Integration with AgentContext") + print("=" * 60) + + print(""" +In a real application, you would configure AgentContext: + + from openhands.sdk import Agent + from openhands.sdk.context import AgentContext + from openhands.sdk.plugin import MarketplaceRegistration + + context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="public", + source="github:OpenHands/extensions", + auto_load="all", + ), + MarketplaceRegistration( + name="company", + source="github:mycompany/internal-plugins", + ref="v2.0.0", + auto_load="all", + ), + MarketplaceRegistration( + name="experimental", + source="github:mycompany/experimental", + # Not auto-loaded, but available for explicit use + ), + ], + ) + + agent = Agent(llm=llm, agent_context=context) + +The SDK will: +1. Fetch marketplaces with auto_load="all" at conversation start +2. Load all plugins from those marketplaces +3. Keep other marketplaces registered for on-demand resolution +""") + + +if __name__ == "__main__": + # Setup example marketplace directories + company_mp, experimental_mp = setup_example_marketplaces() + print(f"Created example marketplaces in: {script_dir / 'marketplaces'}") + + # Run demos + registry = demo_registration(company_mp, experimental_mp) + demo_plugin_resolution(registry) + demo_plugin_loading(registry) + demo_error_handling(registry, experimental_mp) + demo_prefetch(registry) + demo_conversation_integration() + + print("\n" + "=" * 60) + print("EXAMPLE COMPLETED SUCCESSFULLY") + print("=" * 60) + print("EXAMPLE_COST: 0") diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json new file mode 100644 index 0000000000..d7e0ca36ae --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "company-tools", + "owner": { + "name": "DevOps Team", + "email": "devops@company.com" + }, + "description": "Internal company plugins for development workflows", + "plugins": [ + { + "name": "formatter", + "source": "./plugins/formatter", + "description": "Company code formatter with internal style rules" + } + ], + "skills": [] +} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json new file mode 100644 index 0000000000..04d5958f16 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "formatter", + "version": "2.1.0", + "description": "Company code formatter with internal style rules" +} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md new file mode 100644 index 0000000000..7f6f9916ce --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md @@ -0,0 +1,8 @@ +--- +name: formatter-skill +description: Code formatting with company standards +--- + +# Company Formatter Skill + +Apply company code style rules to your project. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json new file mode 100644 index 0000000000..975d8b4a75 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json @@ -0,0 +1,15 @@ +{ + "name": "experimental", + "owner": { + "name": "R&D Team" + }, + "description": "Experimental plugins - use at your own risk", + "plugins": [ + { + "name": "beta-tool", + "source": "./plugins/beta-tool", + "description": "Experimental AI-assisted refactoring" + } + ], + "skills": [] +} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json new file mode 100644 index 0000000000..2661cf01ce --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "beta-tool", + "version": "0.1.0-beta", + "description": "Experimental AI-assisted refactoring" +} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md new file mode 100644 index 0000000000..8f9b942a6c --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md @@ -0,0 +1,8 @@ +--- +name: beta-tool-skill +description: AI-assisted refactoring (experimental) +--- + +# Beta Tool Skill + +Experimental AI-assisted code refactoring. Use with caution. From e80a7f5ea60d34afb712c803619a7567a482ac17 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 19:28:55 +0000 Subject: [PATCH 04/20] refactor(examples): Simplify multiple marketplace example - Reduce to minimal happy path demonstration - Reuse existing 43_mixed_marketplace_skills marketplace - Remove verbose demos and generated directories Co-authored-by: openhands --- .../README.md | 84 +---- .../main.py | 356 ++---------------- .../company-tools/.plugin/marketplace.json | 16 - .../plugins/formatter/.plugin/plugin.json | 5 - .../plugins/formatter/skills/SKILL.md | 8 - .../experimental/.plugin/marketplace.json | 15 - .../plugins/beta-tool/.plugin/plugin.json | 5 - .../plugins/beta-tool/skills/SKILL.md | 8 - 8 files changed, 48 insertions(+), 449 deletions(-) delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md index 709666cd57..31f00a29a8 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md @@ -1,80 +1,30 @@ # Multiple Marketplace Registrations -This example demonstrates how to register multiple plugin marketplaces with different auto-load behaviors using `MarketplaceRegistration` and `MarketplaceRegistry`. +Register multiple marketplaces with different auto-load behaviors. -## Key Concepts - -### MarketplaceRegistration - -Declares a marketplace with its source and auto-load setting: - -```python -MarketplaceRegistration( - name="company", # Identifier for this registration - source="github:mycompany/plugins", # Source: GitHub, git URL, or local path - ref="v2.0.0", # Optional: branch, tag, or commit - repo_path="internal/marketplace", # Optional: subdirectory for monorepos - auto_load="all", # "all" or None -) -``` - -### auto_load Behavior - -| Setting | Behavior | -|---------|----------| -| `auto_load="all"` | Load all plugins from this marketplace at conversation start | -| `auto_load=None` (default) | Register for resolution but don't auto-load plugins | - -### Plugin Resolution - -```python -# Explicit marketplace qualifier -source = registry.resolve_plugin("formatter@company") - -# Search all registered marketplaces (errors if ambiguous) -source = registry.resolve_plugin("formatter") -``` - -## Use Cases - -1. **Enterprise teams** - Internal marketplace + curated public plugins -2. **Multi-team organizations** - Team-specific + shared company marketplaces -3. **Experimental plugins** - Register but don't auto-load until tested - -## Running the Example +## Usage ```bash -cd examples/05_skills_and_plugins/04_multiple_marketplace_registrations python main.py ``` -## Output - -The example demonstrates: -1. Registering multiple marketplaces with different auto-load settings -2. Resolving plugins with explicit and implicit marketplace references -3. Loading plugins from resolved sources -4. Error handling for not found, ambiguous, and unregistered cases -5. Prefetching marketplaces for validation -6. Integration pattern with AgentContext - -## Directory Structure +## Key Concepts -``` -04_multiple_marketplace_registrations/ -├── marketplaces/ # Created by the example -│ ├── company-tools/ -│ │ └── .plugin/marketplace.json -│ │ └── plugins/formatter/... -│ └── experimental/ -│ └── .plugin/marketplace.json -│ └── plugins/beta-tool/... -├── main.py # Example code -└── README.md # This file +```python +registry = MarketplaceRegistry([ + MarketplaceRegistration( + name="primary", + source="github:company/plugins", + auto_load="all", # Load at conversation start + ), + MarketplaceRegistration( + name="secondary", + source="github:company/experimental", + # auto_load=None - available but not auto-loaded + ), +]) ``` ## Related -- [02_loading_plugins](../02_loading_plugins/) - Loading individual plugins -- [43_mixed_marketplace_skills](../../01_standalone_sdk/43_mixed_marketplace_skills/) - Single marketplace with mixed sources -- [Plugin documentation](https://docs.all-hands.dev/sdk/guides/plugins) +- [43_mixed_marketplace_skills](../../01_standalone_sdk/43_mixed_marketplace_skills/) - Example marketplace used here diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 8f943c2f80..2d2181cef4 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -1,352 +1,58 @@ """Example: Multiple Marketplace Registrations -This example demonstrates how to register multiple plugin marketplaces -with different auto-load behaviors using MarketplaceRegistration and -MarketplaceRegistry. +Shows how to register multiple marketplaces and resolve plugins from them. -Key concepts: -1. MarketplaceRegistration - Declares a marketplace with source and auto-load setting -2. MarketplaceRegistry - Manages registered marketplaces with lazy fetching and resolution -3. auto_load="all" - Load all plugins at conversation start -4. auto_load=None - Register for resolution but don't auto-load - -Use cases: -- Enterprise teams with internal + public marketplaces -- Curated plugin sets from multiple sources -- Experimental plugins registered but not auto-loaded - -Plugin resolution: -- "plugin-name@marketplace" - Explicit marketplace reference -- "plugin-name" - Search all registered marketplaces (errors if ambiguous) - -Directory Structure: - 04_multiple_marketplace_registrations/ - ├── marketplaces/ - │ ├── company-tools/ # Internal company marketplace - │ │ └── .plugin/ - │ │ └── marketplace.json - │ │ └── plugins/ - │ │ └── formatter/ - │ │ └── .plugin/ - │ │ └── plugin.json - │ │ └── skills/ - │ │ └── SKILL.md - │ └── experimental/ # Experimental marketplace - │ └── .plugin/ - │ └── marketplace.json - │ └── plugins/ - │ └── beta-tool/ - │ └── .plugin/ - │ └── plugin.json - │ └── skills/ - │ └── SKILL.md - └── main.py +- auto_load="all": Load all plugins at conversation start +- auto_load=None: Register for resolution but don't auto-load """ -import json from pathlib import Path from openhands.sdk.plugin import ( MarketplaceRegistration, MarketplaceRegistry, - Plugin, - PluginNotFoundError, - AmbiguousPluginError, ) +# Reuse the existing example marketplace +EXAMPLE_MARKETPLACE = ( + Path(__file__).parent.parent.parent + / "01_standalone_sdk" + / "43_mixed_marketplace_skills" +) -script_dir = Path(__file__).parent - - -def setup_example_marketplaces() -> tuple[Path, Path]: - """Create example marketplace directories for this demo. - - In production, these would be: - - GitHub repos: github:company/internal-tools - - Git URLs: https://gitlab.internal/team/plugins.git - - Local paths: /opt/company/marketplaces/approved - """ - marketplaces_dir = script_dir / "marketplaces" - - # Create company-tools marketplace - company_mp = marketplaces_dir / "company-tools" - company_meta = company_mp / ".plugin" - company_meta.mkdir(parents=True, exist_ok=True) - - company_manifest = { - "name": "company-tools", - "owner": {"name": "DevOps Team", "email": "devops@company.com"}, - "description": "Internal company plugins for development workflows", - "plugins": [ - { - "name": "formatter", - "source": "./plugins/formatter", - "description": "Company code formatter with internal style rules", - }, - ], - "skills": [], - } - (company_meta / "marketplace.json").write_text(json.dumps(company_manifest, indent=2)) - - # Create formatter plugin - formatter_dir = company_mp / "plugins" / "formatter" - formatter_meta = formatter_dir / ".plugin" - formatter_meta.mkdir(parents=True, exist_ok=True) - (formatter_meta / "plugin.json").write_text(json.dumps({ - "name": "formatter", - "version": "2.1.0", - "description": "Company code formatter with internal style rules", - }, indent=2)) - - formatter_skills = formatter_dir / "skills" - formatter_skills.mkdir(exist_ok=True) - (formatter_skills / "SKILL.md").write_text("""--- -name: formatter-skill -description: Code formatting with company standards ---- - -# Company Formatter Skill - -Apply company code style rules to your project. -""") - - # Create experimental marketplace - experimental_mp = marketplaces_dir / "experimental" - experimental_meta = experimental_mp / ".plugin" - experimental_meta.mkdir(parents=True, exist_ok=True) - - experimental_manifest = { - "name": "experimental", - "owner": {"name": "R&D Team"}, - "description": "Experimental plugins - use at your own risk", - "plugins": [ - { - "name": "beta-tool", - "source": "./plugins/beta-tool", - "description": "Experimental AI-assisted refactoring", - }, - ], - "skills": [], - } - (experimental_meta / "marketplace.json").write_text( - json.dumps(experimental_manifest, indent=2) - ) - - # Create beta-tool plugin - beta_dir = experimental_mp / "plugins" / "beta-tool" - beta_meta = beta_dir / ".plugin" - beta_meta.mkdir(parents=True, exist_ok=True) - (beta_meta / "plugin.json").write_text(json.dumps({ - "name": "beta-tool", - "version": "0.1.0-beta", - "description": "Experimental AI-assisted refactoring", - }, indent=2)) - - beta_skills = beta_dir / "skills" - beta_skills.mkdir(exist_ok=True) - (beta_skills / "SKILL.md").write_text("""--- -name: beta-tool-skill -description: AI-assisted refactoring (experimental) ---- - -# Beta Tool Skill - -Experimental AI-assisted code refactoring. Use with caution. -""") - - return company_mp, experimental_mp - - -def demo_registration(company_mp: Path, experimental_mp: Path) -> MarketplaceRegistry: - """Demo 1: Register multiple marketplaces with different auto-load settings.""" - print("\n" + "=" * 60) - print("DEMO 1: Registering Multiple Marketplaces") - print("=" * 60) +def main(): + # Register multiple marketplaces with different auto-load settings registry = MarketplaceRegistry([ - # Company tools: auto-load all plugins MarketplaceRegistration( - name="company", - source=str(company_mp), - auto_load="all", # Load at conversation start + name="primary", + source=str(EXAMPLE_MARKETPLACE), + auto_load="all", # Load skills at conversation start ), - # Experimental: register but don't auto-load MarketplaceRegistration( - name="experimental", - source=str(experimental_mp), - # auto_load=None (default) - available for resolution but not auto-loaded + name="secondary", + source=str(EXAMPLE_MARKETPLACE), + # auto_load=None (default) - available but not auto-loaded ), ]) - print("\nRegistered marketplaces:") + # Show registered marketplaces + print("Registered marketplaces:") for name, reg in registry.registrations.items(): - auto_load_status = "auto-load" if reg.auto_load == "all" else "on-demand" - print(f" - {name}: {reg.source} ({auto_load_status})") - - auto_load_regs = registry.get_auto_load_registrations() - print(f"\nMarketplaces with auto_load='all': {[r.name for r in auto_load_regs]}") - - return registry - - -def demo_plugin_resolution(registry: MarketplaceRegistry) -> None: - """Demo 2: Resolve plugins from registered marketplaces.""" - print("\n" + "=" * 60) - print("DEMO 2: Resolving Plugins") - print("=" * 60) - - # Resolve with explicit marketplace qualifier - print("\n1. Explicit marketplace qualifier:") - source = registry.resolve_plugin("formatter@company") - print(f" 'formatter@company' -> {source.source}") + status = "auto-load" if reg.auto_load == "all" else "on-demand" + print(f" {name}: {status}") - source = registry.resolve_plugin("beta-tool@experimental") - print(f" 'beta-tool@experimental' -> {source.source}") + # Show which marketplaces will auto-load + auto_load = registry.get_auto_load_registrations() + print(f"\nAuto-load marketplaces: {[r.name for r in auto_load]}") - # Resolve without qualifier (unique name) - print("\n2. Search all marketplaces (unique names):") - source = registry.resolve_plugin("formatter") - print(f" 'formatter' -> {source.source}") - - # List plugins from specific marketplace - print("\n3. List plugins from 'company' marketplace:") - plugins = registry.list_plugins("company") - for name in plugins: - print(f" - {name}") - - # List all plugins across all marketplaces - print("\n4. List plugins from all marketplaces:") - all_plugins = registry.list_plugins() - for name in all_plugins: - print(f" - {name}") - - -def demo_plugin_loading(registry: MarketplaceRegistry) -> None: - """Demo 3: Load plugins using resolved sources.""" - print("\n" + "=" * 60) - print("DEMO 3: Loading Plugins from Resolved Sources") - print("=" * 60) - - # Resolve and load a plugin - source = registry.resolve_plugin("formatter@company") - plugin = Plugin.load(source.source) - - print(f"\nLoaded plugin: {plugin.manifest.name}") - print(f" Version: {plugin.manifest.version}") - print(f" Description: {plugin.manifest.description}") - print(f" Skills: {len(plugin.skills)}") - for skill in plugin.skills: - print(f" - {skill.name}: {skill.description}") - - -def demo_error_handling(registry: MarketplaceRegistry, experimental_mp: Path) -> None: - """Demo 4: Error handling for resolution failures.""" - print("\n" + "=" * 60) - print("DEMO 4: Error Handling") - print("=" * 60) - - # Plugin not found - print("\n1. Plugin not found:") - try: - registry.resolve_plugin("nonexistent@company") - except PluginNotFoundError as e: - print(f" PluginNotFoundError: {e}") - - # Marketplace not registered - print("\n2. Marketplace not registered:") - try: - registry.resolve_plugin("some-plugin@unknown") - except Exception as e: - print(f" {type(e).__name__}: {e}") - - # Ambiguous plugin (add duplicate to demonstrate) - print("\n3. Ambiguous plugin name (simulated):") - # Create a temporary registry with duplicate plugin names - temp_registry = MarketplaceRegistry([ - MarketplaceRegistration(name="mp1", source=str(experimental_mp)), - MarketplaceRegistration(name="mp2", source=str(experimental_mp)), - ]) - try: - temp_registry.resolve_plugin("beta-tool") - except AmbiguousPluginError as e: - print(f" AmbiguousPluginError: {e}") - - -def demo_prefetch(registry: MarketplaceRegistry) -> None: - """Demo 5: Eager prefetching for validation.""" - print("\n" + "=" * 60) - print("DEMO 5: Prefetching Marketplaces") - print("=" * 60) - - print(f"\nCache before prefetch: {len(registry._cache)} entries") - - # Prefetch all registered marketplaces - registry.prefetch_all() - - print(f"Cache after prefetch: {len(registry._cache)} entries") - print("Cached marketplaces:") - for name in registry._cache: - marketplace, path = registry._cache[name] - print(f" - {name}: {len(marketplace.plugins)} plugins") - - -def demo_conversation_integration() -> None: - """Demo 6: How this integrates with AgentContext (conceptual).""" - print("\n" + "=" * 60) - print("DEMO 6: Integration with AgentContext") - print("=" * 60) - - print(""" -In a real application, you would configure AgentContext: - - from openhands.sdk import Agent - from openhands.sdk.context import AgentContext - from openhands.sdk.plugin import MarketplaceRegistration - - context = AgentContext( - registered_marketplaces=[ - MarketplaceRegistration( - name="public", - source="github:OpenHands/extensions", - auto_load="all", - ), - MarketplaceRegistration( - name="company", - source="github:mycompany/internal-plugins", - ref="v2.0.0", - auto_load="all", - ), - MarketplaceRegistration( - name="experimental", - source="github:mycompany/experimental", - # Not auto-loaded, but available for explicit use - ), - ], - ) - - agent = Agent(llm=llm, agent_context=context) - -The SDK will: -1. Fetch marketplaces with auto_load="all" at conversation start -2. Load all plugins from those marketplaces -3. Keep other marketplaces registered for on-demand resolution -""") + # List available skills from a marketplace + marketplace, _ = registry.get_marketplace("primary") + print(f"\nSkills in '{marketplace.name}':") + for skill in marketplace.skills: + print(f" - {skill.name}: {skill.description}") if __name__ == "__main__": - # Setup example marketplace directories - company_mp, experimental_mp = setup_example_marketplaces() - print(f"Created example marketplaces in: {script_dir / 'marketplaces'}") - - # Run demos - registry = demo_registration(company_mp, experimental_mp) - demo_plugin_resolution(registry) - demo_plugin_loading(registry) - demo_error_handling(registry, experimental_mp) - demo_prefetch(registry) - demo_conversation_integration() - - print("\n" + "=" * 60) - print("EXAMPLE COMPLETED SUCCESSFULLY") - print("=" * 60) - print("EXAMPLE_COST: 0") + main() + print("\nEXAMPLE_COST: 0") diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json deleted file mode 100644 index d7e0ca36ae..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/.plugin/marketplace.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "company-tools", - "owner": { - "name": "DevOps Team", - "email": "devops@company.com" - }, - "description": "Internal company plugins for development workflows", - "plugins": [ - { - "name": "formatter", - "source": "./plugins/formatter", - "description": "Company code formatter with internal style rules" - } - ], - "skills": [] -} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json deleted file mode 100644 index 04d5958f16..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/.plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "formatter", - "version": "2.1.0", - "description": "Company code formatter with internal style rules" -} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md deleted file mode 100644 index 7f6f9916ce..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/company-tools/plugins/formatter/skills/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: formatter-skill -description: Code formatting with company standards ---- - -# Company Formatter Skill - -Apply company code style rules to your project. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json deleted file mode 100644 index 975d8b4a75..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/.plugin/marketplace.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "experimental", - "owner": { - "name": "R&D Team" - }, - "description": "Experimental plugins - use at your own risk", - "plugins": [ - { - "name": "beta-tool", - "source": "./plugins/beta-tool", - "description": "Experimental AI-assisted refactoring" - } - ], - "skills": [] -} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json deleted file mode 100644 index 2661cf01ce..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/.plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "beta-tool", - "version": "0.1.0-beta", - "description": "Experimental AI-assisted refactoring" -} \ No newline at end of file diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md deleted file mode 100644 index 8f9b942a6c..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/marketplaces/experimental/plugins/beta-tool/skills/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: beta-tool-skill -description: AI-assisted refactoring (experimental) ---- - -# Beta Tool Skill - -Experimental AI-assisted code refactoring. Use with caution. From 8fe1850f7d3c0034a7a34712581d4a0b9da43c8f Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 20:36:09 +0000 Subject: [PATCH 05/20] refactor(examples): Use Agent + Conversation pattern for marketplace example Shows the user-facing API: - Configure registered_marketplaces in AgentContext - Create Agent with agent_context - Use skills from marketplace in a Conversation Co-authored-by: openhands --- .../README.md | 32 ++++--- .../main.py | 83 ++++++++++--------- 2 files changed, 65 insertions(+), 50 deletions(-) diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md index 31f00a29a8..89394ebd2b 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md @@ -1,28 +1,34 @@ # Multiple Marketplace Registrations -Register multiple marketplaces with different auto-load behaviors. +Register multiple marketplaces and use their skills in a conversation. ## Usage ```bash +export LLM_API_KEY=your-api-key python main.py ``` ## Key Concepts ```python -registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="primary", - source="github:company/plugins", - auto_load="all", # Load at conversation start - ), - MarketplaceRegistration( - name="secondary", - source="github:company/experimental", - # auto_load=None - available but not auto-loaded - ), -]) +agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="company", + source="github:company/plugins", + auto_load="all", # Load skills at conversation start + ), + MarketplaceRegistration( + name="experimental", + source="github:company/experimental", + # auto_load=None - registered but not auto-loaded + ), + ], +) + +agent = Agent(llm=llm, tools=tools, agent_context=agent_context) +conversation = Conversation(agent=agent, workspace=workspace) ``` ## Related diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 2d2181cef4..0ce2567204 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -1,17 +1,17 @@ """Example: Multiple Marketplace Registrations -Shows how to register multiple marketplaces and resolve plugins from them. +Register multiple marketplaces and use their skills in a conversation. -- auto_load="all": Load all plugins at conversation start -- auto_load=None: Register for resolution but don't auto-load +- auto_load="all": Load all skills at conversation start +- auto_load=None: Register but don't auto-load (available for explicit use) """ +import os from pathlib import Path -from openhands.sdk.plugin import ( - MarketplaceRegistration, - MarketplaceRegistry, -) +from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool +from openhands.sdk.plugin import MarketplaceRegistration +from openhands.tools.terminal import TerminalTool # Reuse the existing example marketplace EXAMPLE_MARKETPLACE = ( @@ -22,37 +22,46 @@ def main(): + llm = LLM( + model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + api_key=os.getenv("LLM_API_KEY"), + base_url=os.getenv("LLM_BASE_URL"), + ) + # Register multiple marketplaces with different auto-load settings - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="primary", - source=str(EXAMPLE_MARKETPLACE), - auto_load="all", # Load skills at conversation start - ), - MarketplaceRegistration( - name="secondary", - source=str(EXAMPLE_MARKETPLACE), - # auto_load=None (default) - available but not auto-loaded - ), - ]) - - # Show registered marketplaces - print("Registered marketplaces:") - for name, reg in registry.registrations.items(): - status = "auto-load" if reg.auto_load == "all" else "on-demand" - print(f" {name}: {status}") - - # Show which marketplaces will auto-load - auto_load = registry.get_auto_load_registrations() - print(f"\nAuto-load marketplaces: {[r.name for r in auto_load]}") - - # List available skills from a marketplace - marketplace, _ = registry.get_marketplace("primary") - print(f"\nSkills in '{marketplace.name}':") - for skill in marketplace.skills: - print(f" - {skill.name}: {skill.description}") + agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="company", + source=str(EXAMPLE_MARKETPLACE), + auto_load="all", # Load skills at conversation start + ), + MarketplaceRegistration( + name="experimental", + source=str(EXAMPLE_MARKETPLACE), + # auto_load=None (default) - registered but not auto-loaded + ), + ], + ) + + agent = Agent( + llm=llm, + tools=[Tool(name=TerminalTool.name)], + agent_context=agent_context, + ) + + conversation = Conversation(agent=agent, workspace=os.getcwd()) + + # The "greeting-helper" skill from the marketplace should be available + conversation.send_message("Use the greeting helper skill to greet me!") + conversation.run() + + print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") if __name__ == "__main__": - main() - print("\nEXAMPLE_COST: 0") + if not os.getenv("LLM_API_KEY"): + print("Set LLM_API_KEY to run this example") + print("EXAMPLE_COST: 0") + else: + main() From ddcc90ac9bd3c5612b8c9e56848489de6efd98a3 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 22:53:33 +0000 Subject: [PATCH 06/20] feat(sdk): Add conversation.load_plugin() for on-demand plugin loading - Add load_plugin(plugin_ref) to BaseConversation abstract interface - Implement in LocalConversation: creates MarketplaceRegistry from agent_context.registered_marketplaces, resolves and loads plugin - Implement in RemoteConversation: calls agent-server API endpoint - Add POST /conversations/{id}/plugins/load endpoint to agent-server - Add LoadPluginRequest model - Add ConversationService.load_plugin() and EventService.load_plugin() - Add tests for conversation.load_plugin() integration - Update example to demonstrate the full API Co-authored-by: openhands --- .../README.md | 10 +- .../main.py | 66 ++++----- .../agent_server/conversation_router.py | 17 +++ .../agent_server/conversation_service.py | 12 ++ .../openhands/agent_server/event_service.py | 13 ++ .../openhands/agent_server/models.py | 11 ++ .../openhands/sdk/conversation/base.py | 23 ++++ .../conversation/impl/local_conversation.py | 76 +++++++++++ .../conversation/impl/remote_conversation.py | 27 ++++ .../test_marketplace_registry_integration.py | 125 ++++++++++++++++++ 10 files changed, 347 insertions(+), 33 deletions(-) diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md index 89394ebd2b..bdc1ecfed4 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/README.md @@ -1,23 +1,23 @@ # Multiple Marketplace Registrations -Register multiple marketplaces and use their skills in a conversation. +Register multiple marketplaces and load plugins on-demand. ## Usage ```bash -export LLM_API_KEY=your-api-key python main.py ``` ## Key Concepts ```python +# Configure marketplaces in AgentContext agent_context = AgentContext( registered_marketplaces=[ MarketplaceRegistration( name="company", source="github:company/plugins", - auto_load="all", # Load skills at conversation start + auto_load="all", # Load all plugins at conversation start ), MarketplaceRegistration( name="experimental", @@ -27,8 +27,12 @@ agent_context = AgentContext( ], ) +# Create agent and conversation agent = Agent(llm=llm, tools=tools, agent_context=agent_context) conversation = Conversation(agent=agent, workspace=workspace) + +# Load a plugin on-demand from registered marketplace +conversation.load_plugin("beta-tool@experimental") ``` ## Related diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 0ce2567204..8caab61850 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -1,19 +1,17 @@ """Example: Multiple Marketplace Registrations -Register multiple marketplaces and use their skills in a conversation. +Register multiple marketplaces and load plugins on-demand. -- auto_load="all": Load all skills at conversation start -- auto_load=None: Register but don't auto-load (available for explicit use) +- auto_load="all": Load all plugins at conversation start +- auto_load=None: Register but don't auto-load (use conversation.load_plugin()) """ -import os from pathlib import Path -from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool -from openhands.sdk.plugin import MarketplaceRegistration -from openhands.tools.terminal import TerminalTool +from openhands.sdk import AgentContext +from openhands.sdk.plugin import MarketplaceRegistration, MarketplaceRegistry -# Reuse the existing example marketplace +# Reuse the existing example marketplace (has skills, not plugins) EXAMPLE_MARKETPLACE = ( Path(__file__).parent.parent.parent / "01_standalone_sdk" @@ -22,19 +20,13 @@ def main(): - llm = LLM( - model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), - api_key=os.getenv("LLM_API_KEY"), - base_url=os.getenv("LLM_BASE_URL"), - ) - # Register multiple marketplaces with different auto-load settings agent_context = AgentContext( registered_marketplaces=[ MarketplaceRegistration( name="company", source=str(EXAMPLE_MARKETPLACE), - auto_load="all", # Load skills at conversation start + auto_load="all", # Load all plugins at conversation start ), MarketplaceRegistration( name="experimental", @@ -44,24 +36,38 @@ def main(): ], ) - agent = Agent( - llm=llm, - tools=[Tool(name=TerminalTool.name)], - agent_context=agent_context, - ) + print("Configured AgentContext with registered_marketplaces:") + for reg in agent_context.registered_marketplaces: + status = "auto-load" if reg.auto_load == "all" else "on-demand" + print(f" {reg.name}: {status}") - conversation = Conversation(agent=agent, workspace=os.getcwd()) + # The registry can be used to inspect/resolve plugins before conversation + registry = MarketplaceRegistry(agent_context.registered_marketplaces) + + # List available skills from the marketplace + marketplace, _ = registry.get_marketplace("company") + print(f"\nSkills in '{marketplace.name}':") + for skill in marketplace.skills: + print(f" - {skill.name}: {skill.description}") - # The "greeting-helper" skill from the marketplace should be available - conversation.send_message("Use the greeting helper skill to greet me!") - conversation.run() + # Example usage with Conversation (requires LLM_API_KEY): + print(""" +To use in a conversation: - print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + from openhands.sdk import LLM, Agent, Conversation + + agent = Agent(llm=llm, tools=tools, agent_context=agent_context) + conversation = Conversation(agent=agent, workspace=workspace) + + # Load a plugin on-demand from registered marketplace + conversation.load_plugin("plugin-name@experimental") + + # Use the loaded plugin's skills + conversation.send_message("...") + conversation.run() +""") if __name__ == "__main__": - if not os.getenv("LLM_API_KEY"): - print("Set LLM_API_KEY to run this example") - print("EXAMPLE_COST: 0") - else: - main() + main() + print("EXAMPLE_COST: 0") diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index 12f52f46dc..df97cc4102 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -19,6 +19,7 @@ ConversationSortOrder, GenerateTitleRequest, GenerateTitleResponse, + LoadPluginRequest, SendMessageRequest, SetConfirmationPolicyRequest, SetSecurityAnalyzerRequest, @@ -338,3 +339,19 @@ async def condense_conversation( if not success: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found") return Success() + + +@conversation_router.post( + "/{conversation_id}/plugins/load", + responses={404: {"description": "Item not found"}}, +) +async def load_plugin( + conversation_id: UUID, + request: LoadPluginRequest, + conversation_service: ConversationService = Depends(get_conversation_service), +) -> Success: + """Load a plugin from a registered marketplace into the conversation.""" + success = await conversation_service.load_plugin(conversation_id, request.plugin_ref) + if not success: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found") + return Success() diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 37b715191a..9d2e7cd265 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -625,6 +625,18 @@ async def condense(self, conversation_id: UUID) -> bool: await event_service.condense() return True + async def load_plugin(self, conversation_id: UUID, plugin_ref: str) -> bool: + """Load a plugin from a registered marketplace into the conversation.""" + if self._event_services is None: + raise ValueError("inactive_service") + event_service = self._event_services.get(conversation_id) + if event_service is None: + return False + + # Delegate to EventService to access conversation internals + await event_service.load_plugin(plugin_ref) + return True + async def __aenter__(self): self.conversations_dir.mkdir(parents=True, exist_ok=True) self._event_services = {} diff --git a/openhands-agent-server/openhands/agent_server/event_service.py b/openhands-agent-server/openhands/agent_server/event_service.py index 953251442a..3740e16a13 100644 --- a/openhands-agent-server/openhands/agent_server/event_service.py +++ b/openhands-agent-server/openhands/agent_server/event_service.py @@ -660,6 +660,19 @@ async def condense(self) -> None: loop = asyncio.get_running_loop() return await loop.run_in_executor(None, self._conversation.condense) + async def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Delegates to LocalConversation in an executor to avoid blocking the event loop. + """ + if not self._conversation: + raise ValueError("inactive_service") + + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, self._conversation.load_plugin, plugin_ref + ) + async def get_state(self) -> ConversationState: if not self._conversation: raise ValueError("inactive_service") diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index 906fc95087..26fc28a2b2 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -418,6 +418,17 @@ class AskAgentResponse(BaseModel): response: str = Field(description="The agent's response to the question") +class LoadPluginRequest(BaseModel): + """Payload to load a plugin from a registered marketplace.""" + + plugin_ref: str = Field( + description=( + "Plugin reference to resolve and load. " + "Can be 'plugin-name@marketplace-name' or just 'plugin-name'." + ) + ) + + class BashEventBase(DiscriminatedUnionMixin, ABC): """Base class for all bash event types""" diff --git a/openhands-sdk/openhands/sdk/conversation/base.py b/openhands-sdk/openhands/sdk/conversation/base.py index f131889d1e..96db857d88 100644 --- a/openhands-sdk/openhands/sdk/conversation/base.py +++ b/openhands-sdk/openhands/sdk/conversation/base.py @@ -304,6 +304,29 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: """ ... + @abstractmethod + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + and loads the plugin into the conversation. The plugin's skills, hooks, + and MCP configuration will be merged into the agent. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + ... + @staticmethod def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType: """Compose multiple callbacks into a single callback function. diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 68b0e4997a..971be7fb42 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -42,6 +42,7 @@ from openhands.sdk.logger import get_logger from openhands.sdk.observability.laminar import observe from openhands.sdk.plugin import ( + MarketplaceRegistry, Plugin, PluginSource, ResolvedPluginSource, @@ -82,6 +83,7 @@ class LocalConversation(BaseConversation): _resolved_plugins: list[ResolvedPluginSource] | None _plugins_loaded: bool _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks + _marketplace_registry: MarketplaceRegistry | None # Lazy-initialized from agent_context def __init__( self, @@ -156,6 +158,7 @@ def __init__( self._plugins_loaded = False self._pending_hook_config = hook_config # Will be combined with plugin hooks self._agent_ready = False # Agent initialized lazily after plugins loaded + self._marketplace_registry = None # Lazy-initialized from agent_context self.agent = agent if isinstance(workspace, (str, Path)): @@ -1124,6 +1127,79 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: raise NotImplementedError(f"Tool '{tool_name}' has no executor") return tool(action, self) + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + and loads the plugin into the conversation. The plugin's skills, hooks, + and MCP configuration will be merged into the agent. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + # Ensure plugins loaded first (initializes agent context) + self._ensure_plugins_loaded() + + # Lazy-initialize the marketplace registry from agent_context + if self._marketplace_registry is None: + registrations = self.agent.agent_context.registered_marketplaces + if not registrations: + raise ValueError( + "No marketplaces registered. Configure registered_marketplaces " + "in AgentContext to use load_plugin()." + ) + self._marketplace_registry = MarketplaceRegistry(registrations) + + # Resolve the plugin reference + resolved_source = self._marketplace_registry.resolve_plugin(plugin_ref) + + # Fetch and load the plugin + path, resolved_ref = fetch_plugin_with_resolution( + source=resolved_source.source, + ref=resolved_source.ref, + repo_path=resolved_source.repo_path, + ) + + plugin = Plugin.load(path) + logger.info( + f"Loaded plugin '{plugin.manifest.name}' from {resolved_source.source}" + + (f" @ {resolved_ref[:8]}" if resolved_ref else "") + ) + + # Merge plugin contents into agent + merged_context = plugin.add_skills_to(self.agent.agent_context) + merged_mcp = plugin.add_mcp_config_to( + dict(self.agent.mcp_config) if self.agent.mcp_config else {} + ) + + # Update agent with merged content + self.agent = self.agent.model_copy( + update={ + "agent_context": merged_context, + "mcp_config": merged_mcp, + } + ) + + # Update agent in state for API observability + with self._state: + self._state.agent = self.agent + + # Track resolved plugin + resolved = ResolvedPluginSource.from_plugin_source(resolved_source, resolved_ref) + if self._resolved_plugins is None: + self._resolved_plugins = [] + self._resolved_plugins.append(resolved) + def __del__(self) -> None: """Ensure cleanup happens when conversation is destroyed.""" try: diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 05e91b913e..3c3118fa1d 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -1316,6 +1316,33 @@ def execute_tool(self, tool_name: str, action: "Action") -> "Observation": "tool execution." ) + def load_plugin(self, plugin_ref: str) -> None: + """Load a plugin from a registered marketplace. + + Resolves the plugin reference against the agent's registered marketplaces + on the server and loads the plugin into the conversation. + + Plugin references can be: + - "plugin-name@marketplace-name" - Explicit marketplace qualifier + - "plugin-name" - Search all registered marketplaces (errors if ambiguous) + + Args: + plugin_ref: Plugin reference to resolve and load. + + Raises: + PluginNotFoundError: If the plugin is not found. + AmbiguousPluginError: If the plugin name matches multiple marketplaces. + MarketplaceNotFoundError: If a specified marketplace is not registered. + ValueError: If no marketplaces are registered. + """ + payload = {"plugin_ref": plugin_ref} + _send_request( + self._client, + "POST", + f"{self._conversation_action_base_path}/{self._id}/plugins/load", + json=payload, + ) + def close(self) -> None: """Close the conversation and clean up resources. diff --git a/tests/sdk/plugin/test_marketplace_registry_integration.py b/tests/sdk/plugin/test_marketplace_registry_integration.py index 1b6ee173da..01265eec90 100644 --- a/tests/sdk/plugin/test_marketplace_registry_integration.py +++ b/tests/sdk/plugin/test_marketplace_registry_integration.py @@ -469,3 +469,128 @@ def test_marketplace_with_claude_plugin_directory(self, tmp_path): source = registry.resolve_plugin("claude-plugin@claude") assert "claude-plugin" in source.source + + +class TestConversationLoadPlugin: + """Test Conversation.load_plugin() integration with MarketplaceRegistry.""" + + def test_load_plugin_from_marketplace(self, tmp_path): + """Test loading a plugin via conversation.load_plugin().""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create a marketplace with a plugin + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "test-plugin", "description": "A test plugin"}], + ) + + # Create agent with registered marketplace (use dummy LLM - won't make calls) + llm = LLM(model="test/model", api_key="test-key") + + agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="test", + source=str(marketplace_dir), + ), + ], + ) + + agent = Agent( + llm=llm, + tools=[], + agent_context=agent_context, + ) + + # Create conversation + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + # Verify no resolved_plugins yet + assert conversation.resolved_plugins is None + + # Load the plugin + conversation.load_plugin("test-plugin@test") + + # Verify resolved_plugins was updated + assert conversation.resolved_plugins is not None + assert len(conversation.resolved_plugins) == 1 + assert conversation.resolved_plugins[0].source == f"{marketplace_dir}/plugins/test-plugin" + + conversation.close() + + def test_load_plugin_no_marketplaces_registered(self, tmp_path): + """Test that load_plugin raises ValueError when no marketplaces registered.""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create agent without registered marketplaces + llm = LLM(model="test/model", api_key="test-key") + + agent = Agent( + llm=llm, + tools=[], + agent_context=AgentContext(), + ) + + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + with pytest.raises(ValueError, match="No marketplaces registered"): + conversation.load_plugin("some-plugin") + + conversation.close() + + def test_load_plugin_not_found(self, tmp_path): + """Test that load_plugin raises PluginNotFoundError for missing plugins.""" + from openhands.sdk import LLM, Agent, AgentContext + from openhands.sdk.conversation import Conversation + + # Create a marketplace with a plugin + marketplace_dir = create_marketplace( + tmp_path, + name="test-marketplace", + plugins=[{"name": "existing-plugin"}], + ) + + llm = LLM(model="test/model", api_key="test-key") + + agent_context = AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="test", + source=str(marketplace_dir), + ), + ], + ) + + agent = Agent( + llm=llm, + tools=[], + agent_context=agent_context, + ) + + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + + conversation = Conversation( + agent=agent, + workspace=str(workspace_dir), + ) + + with pytest.raises(PluginNotFoundError): + conversation.load_plugin("nonexistent-plugin@test") + + conversation.close() From 5822988b0050d33bf0978edd86c28910a1dff131 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 23:06:26 +0000 Subject: [PATCH 07/20] fix(examples): Make marketplace example a proper runnable demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create marketplace and plugin on-the-fly instead of printing code - Shows the complete flow: register → load_plugin → use skill - Add .gitignore for generated demo_marketplace directory Co-authored-by: openhands --- .../.gitignore | 1 + .../main.py | 111 ++++++++++-------- 2 files changed, 66 insertions(+), 46 deletions(-) create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore new file mode 100644 index 0000000000..71a5c11e64 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore @@ -0,0 +1 @@ +demo_marketplace/ diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 8caab61850..b238ddbc45 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -6,68 +6,87 @@ - auto_load=None: Register but don't auto-load (use conversation.load_plugin()) """ +import json +import os from pathlib import Path -from openhands.sdk import AgentContext -from openhands.sdk.plugin import MarketplaceRegistration, MarketplaceRegistry +from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk.plugin import MarketplaceRegistration -# Reuse the existing example marketplace (has skills, not plugins) -EXAMPLE_MARKETPLACE = ( - Path(__file__).parent.parent.parent - / "01_standalone_sdk" - / "43_mixed_marketplace_skills" -) +SCRIPT_DIR = Path(__file__).parent + + +def create_example_marketplace() -> Path: + """Create a simple marketplace with a plugin for this demo.""" + marketplace_dir = SCRIPT_DIR / "demo_marketplace" + plugin_dir = marketplace_dir / "plugins" / "greeter" + + # Create marketplace manifest + (marketplace_dir / ".plugin").mkdir(parents=True, exist_ok=True) + (marketplace_dir / ".plugin" / "marketplace.json").write_text(json.dumps({ + "name": "demo-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], + "skills": [], + })) + + # Create plugin with a skill + (plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True) + (plugin_dir / ".plugin" / "plugin.json").write_text(json.dumps({ + "name": "greeter", + "version": "1.0.0", + "description": "A greeting plugin", + })) + + (plugin_dir / "skills").mkdir(exist_ok=True) + (plugin_dir / "skills" / "SKILL.md").write_text("""--- +name: greeter-skill +description: Generates friendly greetings +--- +# Greeter Skill +When asked to greet someone, respond with a warm, friendly greeting. +""") + + return marketplace_dir def main(): - # Register multiple marketplaces with different auto-load settings + marketplace_dir = create_example_marketplace() + + llm = LLM( + model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + api_key=os.getenv("LLM_API_KEY"), + base_url=os.getenv("LLM_BASE_URL"), + ) + + # Register marketplace (not auto-loaded) agent_context = AgentContext( registered_marketplaces=[ MarketplaceRegistration( - name="company", - source=str(EXAMPLE_MARKETPLACE), - auto_load="all", # Load all plugins at conversation start - ), - MarketplaceRegistration( - name="experimental", - source=str(EXAMPLE_MARKETPLACE), - # auto_load=None (default) - registered but not auto-loaded + name="demo", + source=str(marketplace_dir), + # auto_load=None - we'll load explicitly ), ], ) - print("Configured AgentContext with registered_marketplaces:") - for reg in agent_context.registered_marketplaces: - status = "auto-load" if reg.auto_load == "all" else "on-demand" - print(f" {reg.name}: {status}") + agent = Agent(llm=llm, tools=[], agent_context=agent_context) + conversation = Conversation(agent=agent, workspace=os.getcwd()) - # The registry can be used to inspect/resolve plugins before conversation - registry = MarketplaceRegistry(agent_context.registered_marketplaces) - - # List available skills from the marketplace - marketplace, _ = registry.get_marketplace("company") - print(f"\nSkills in '{marketplace.name}':") - for skill in marketplace.skills: - print(f" - {skill.name}: {skill.description}") + # Load the plugin on-demand + conversation.load_plugin("greeter@demo") + print(f"Loaded: {conversation.resolved_plugins[0].source}") - # Example usage with Conversation (requires LLM_API_KEY): - print(""" -To use in a conversation: - - from openhands.sdk import LLM, Agent, Conversation - - agent = Agent(llm=llm, tools=tools, agent_context=agent_context) - conversation = Conversation(agent=agent, workspace=workspace) - - # Load a plugin on-demand from registered marketplace - conversation.load_plugin("plugin-name@experimental") - - # Use the loaded plugin's skills - conversation.send_message("...") + # Use the skill + conversation.send_message("Please greet me!") conversation.run() -""") + + print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") if __name__ == "__main__": - main() - print("EXAMPLE_COST: 0") + if not os.getenv("LLM_API_KEY"): + print("Set LLM_API_KEY to run this example") + print("EXAMPLE_COST: 0") + else: + main() From 33a45a000ddd4f98f344be2890f8d58b9aa67d5f Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 18 Mar 2026 23:59:47 +0000 Subject: [PATCH 08/20] fix: Fix CI failures - lint, type errors, and version bump to 1.15.0 - Fix line too long errors in agent_context.py, registry.py, local_conversation.py, and test files - Fix unused variable assignments in test_marketplace_registry_integration.py - Fix pyright errors: - Add null check for agent_context in local_conversation.py - Add null check for resolved_plugins in example file - Add missing load_plugin method to MockConversation in test file - Bump version from 1.14.0 to 1.15.0 for breaking API change (marketplace_path field marked as deprecated) - Apply ruff formatter fixes (import sorting, whitespace) --- .../main.py | 47 ++- .../agent_server/conversation_router.py | 4 +- openhands-agent-server/pyproject.toml | 2 +- .../openhands/sdk/context/agent_context.py | 3 +- .../conversation/impl/local_conversation.py | 14 +- .../openhands/sdk/plugin/registry.py | 19 +- openhands-sdk/openhands/sdk/plugin/types.py | 4 +- openhands-sdk/pyproject.toml | 2 +- openhands-tools/pyproject.toml | 2 +- openhands-workspace/pyproject.toml | 2 +- .../conversation/test_base_span_management.py | 4 + tests/sdk/plugin/test_marketplace_registry.py | 109 +++-- .../test_marketplace_registry_integration.py | 372 ++++++++++-------- uv.lock | 8 +- 14 files changed, 316 insertions(+), 276 deletions(-) diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index b238ddbc45..87afdd0955 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -2,7 +2,7 @@ Register multiple marketplaces and load plugins on-demand. -- auto_load="all": Load all plugins at conversation start +- auto_load="all": Load all plugins at conversation start - auto_load=None: Register but don't auto-load (use conversation.load_plugin()) """ @@ -13,6 +13,7 @@ from openhands.sdk import LLM, Agent, AgentContext, Conversation from openhands.sdk.plugin import MarketplaceRegistration + SCRIPT_DIR = Path(__file__).parent @@ -20,24 +21,32 @@ def create_example_marketplace() -> Path: """Create a simple marketplace with a plugin for this demo.""" marketplace_dir = SCRIPT_DIR / "demo_marketplace" plugin_dir = marketplace_dir / "plugins" / "greeter" - + # Create marketplace manifest (marketplace_dir / ".plugin").mkdir(parents=True, exist_ok=True) - (marketplace_dir / ".plugin" / "marketplace.json").write_text(json.dumps({ - "name": "demo-marketplace", - "owner": {"name": "Demo"}, - "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], - "skills": [], - })) - + (marketplace_dir / ".plugin" / "marketplace.json").write_text( + json.dumps( + { + "name": "demo-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], + "skills": [], + } + ) + ) + # Create plugin with a skill (plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True) - (plugin_dir / ".plugin" / "plugin.json").write_text(json.dumps({ - "name": "greeter", - "version": "1.0.0", - "description": "A greeting plugin", - })) - + (plugin_dir / ".plugin" / "plugin.json").write_text( + json.dumps( + { + "name": "greeter", + "version": "1.0.0", + "description": "A greeting plugin", + } + ) + ) + (plugin_dir / "skills").mkdir(exist_ok=True) (plugin_dir / "skills" / "SKILL.md").write_text("""--- name: greeter-skill @@ -46,13 +55,13 @@ def create_example_marketplace() -> Path: # Greeter Skill When asked to greet someone, respond with a warm, friendly greeting. """) - + return marketplace_dir def main(): marketplace_dir = create_example_marketplace() - + llm = LLM( model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), api_key=os.getenv("LLM_API_KEY"), @@ -75,7 +84,9 @@ def main(): # Load the plugin on-demand conversation.load_plugin("greeter@demo") - print(f"Loaded: {conversation.resolved_plugins[0].source}") + resolved = conversation.resolved_plugins + if resolved: + print(f"Loaded: {resolved[0].source}") # Use the skill conversation.send_message("Please greet me!") diff --git a/openhands-agent-server/openhands/agent_server/conversation_router.py b/openhands-agent-server/openhands/agent_server/conversation_router.py index df97cc4102..2f28c7bec2 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_router.py +++ b/openhands-agent-server/openhands/agent_server/conversation_router.py @@ -351,7 +351,9 @@ async def load_plugin( conversation_service: ConversationService = Depends(get_conversation_service), ) -> Success: """Load a plugin from a registered marketplace into the conversation.""" - success = await conversation_service.load_plugin(conversation_id, request.plugin_ref) + success = await conversation_service.load_plugin( + conversation_id, request.plugin_ref + ) if not success: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation not found") return Success() diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index c7573f4b6f..5d5b2ea6c0 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index 6222f29e3c..30f0242647 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -92,7 +92,8 @@ class AgentContext(BaseModel): description=( "List of marketplace registrations for plugin resolution. " "Marketplaces with auto_load='all' will have their plugins loaded " - "automatically at conversation start. See MarketplaceRegistration for details." + "automatically at conversation start. " + "See MarketplaceRegistration for details." ), ) secrets: Mapping[str, SecretValue] | None = Field( diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 971be7fb42..4368685e70 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -83,7 +83,7 @@ class LocalConversation(BaseConversation): _resolved_plugins: list[ResolvedPluginSource] | None _plugins_loaded: bool _pending_hook_config: HookConfig | None # Hook config to combine with plugin hooks - _marketplace_registry: MarketplaceRegistry | None # Lazy-initialized from agent_context + _marketplace_registry: MarketplaceRegistry | None # Lazy-init from agent_context def __init__( self, @@ -1152,7 +1152,13 @@ def load_plugin(self, plugin_ref: str) -> None: # Lazy-initialize the marketplace registry from agent_context if self._marketplace_registry is None: - registrations = self.agent.agent_context.registered_marketplaces + agent_context = self.agent.agent_context + if agent_context is None: + raise ValueError( + "No agent context available. Configure agent_context " + "with registered_marketplaces to use load_plugin()." + ) + registrations = agent_context.registered_marketplaces if not registrations: raise ValueError( "No marketplaces registered. Configure registered_marketplaces " @@ -1195,7 +1201,9 @@ def load_plugin(self, plugin_ref: str) -> None: self._state.agent = self.agent # Track resolved plugin - resolved = ResolvedPluginSource.from_plugin_source(resolved_source, resolved_ref) + resolved = ResolvedPluginSource.from_plugin_source( + resolved_source, resolved_ref + ) if self._resolved_plugins is None: self._resolved_plugins = [] self._resolved_plugins.append(resolved) diff --git a/openhands-sdk/openhands/sdk/plugin/registry.py b/openhands-sdk/openhands/sdk/plugin/registry.py index df7f2e5dcd..50af6ebde0 100644 --- a/openhands-sdk/openhands/sdk/plugin/registry.py +++ b/openhands-sdk/openhands/sdk/plugin/registry.py @@ -42,7 +42,9 @@ def __init__(self, plugin_name: str, marketplace_name: str | None = None): self.plugin_name = plugin_name self.marketplace_name = marketplace_name if marketplace_name: - msg = f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'" + msg = ( + f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'" + ) else: msg = f"Plugin '{plugin_name}' not found in any registered marketplace" super().__init__(msg) @@ -89,7 +91,8 @@ def __init__(self, registrations: list[MarketplaceRegistration] | None = None): registrations: List of marketplace registrations. Can be empty or None. """ self._registrations: dict[str, MarketplaceRegistration] = {} - self._cache: dict[str, tuple[Marketplace, Path]] = {} # name -> (marketplace, path) + # Maps name to (marketplace, path) + self._cache: dict[str, tuple[Marketplace, Path]] = {} if registrations: for reg in registrations: @@ -102,12 +105,11 @@ def registrations(self) -> dict[str, MarketplaceRegistration]: def get_auto_load_registrations(self) -> list[MarketplaceRegistration]: """Get registrations with auto_load='all'.""" - return [ - reg for reg in self._registrations.values() - if reg.auto_load == "all" - ] + return [reg for reg in self._registrations.values() if reg.auto_load == "all"] - def _fetch_marketplace(self, reg: MarketplaceRegistration) -> tuple[Marketplace, Path]: + def _fetch_marketplace( + self, reg: MarketplaceRegistration + ) -> tuple[Marketplace, Path]: """Fetch a marketplace and return (Marketplace, repo_path). This is the internal method that does the actual fetching. @@ -243,7 +245,8 @@ def _resolve_from_all(self, plugin_name: str) -> PluginSource: except Exception as e: logger.warning( - f"Error searching marketplace '{name}' for plugin '{plugin_name}': {e}" + f"Error searching marketplace '{name}' " + f"for plugin '{plugin_name}': {e}" ) if not matches: diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index 478d0ff46d..9f85679d89 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -45,9 +45,7 @@ class MarketplaceRegistration(BaseModel): ... ) """ - name: str = Field( - description="Identifier for this marketplace registration" - ) + name: str = Field(description="Identifier for this marketplace registration") source: str = Field( description="Marketplace source: 'github:owner/repo', git URL, or local path" ) diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 248921c6e2..6eec53bf9b 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.14.0" +version = "1.15.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 3677f4c6f6..410ad98638 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index 1d4a314d64..ce2747250b 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/tests/sdk/conversation/test_base_span_management.py b/tests/sdk/conversation/test_base_span_management.py index 01c1ddb536..4094a7962b 100644 --- a/tests/sdk/conversation/test_base_span_management.py +++ b/tests/sdk/conversation/test_base_span_management.py @@ -68,6 +68,10 @@ def execute_tool(self, tool_name: str, action: Action) -> Observation: """Mock implementation of execute_tool method.""" raise NotImplementedError("Mock execute_tool not implemented") + def load_plugin(self, plugin_ref: str) -> None: + """Mock implementation of load_plugin method.""" + pass + def test_base_conversation_span_management(): """Test that BaseConversation properly manages span state to prevent double-ending.""" # noqa: E501 diff --git a/tests/sdk/plugin/test_marketplace_registry.py b/tests/sdk/plugin/test_marketplace_registry.py index c6d439c8cd..12dc0869e1 100644 --- a/tests/sdk/plugin/test_marketplace_registry.py +++ b/tests/sdk/plugin/test_marketplace_registry.py @@ -1,20 +1,17 @@ """Tests for MarketplaceRegistry and MarketplaceRegistration.""" import json +from unittest.mock import patch + import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock from openhands.sdk.plugin import ( + AmbiguousPluginError, + Marketplace, + MarketplaceNotFoundError, MarketplaceRegistration, MarketplaceRegistry, - PluginSource, PluginNotFoundError, - AmbiguousPluginError, - MarketplaceNotFoundError, - Marketplace, - MarketplaceOwner, - MarketplacePluginEntry, ) @@ -95,7 +92,7 @@ def test_registry_with_registrations(self): MarketplaceRegistration(name="b", source="github:owner/b"), ] registry = MarketplaceRegistry(regs) - + assert len(registry.registrations) == 2 assert "a" in registry.registrations assert "b" in registry.registrations @@ -108,7 +105,7 @@ def test_get_auto_load_registrations(self): MarketplaceRegistration(name="c", source="github:owner/c", auto_load="all"), ] registry = MarketplaceRegistry(regs) - + auto_load = registry.get_auto_load_registrations() assert len(auto_load) == 2 assert all(r.auto_load == "all" for r in auto_load) @@ -116,10 +113,10 @@ def test_get_auto_load_registrations(self): def test_marketplace_not_found_error(self): """Test error when marketplace not registered.""" registry = MarketplaceRegistry() - + with pytest.raises(MarketplaceNotFoundError) as exc_info: registry.get_marketplace("nonexistent") - + assert exc_info.value.marketplace_name == "nonexistent" def test_parse_plugin_ref_simple(self): @@ -151,7 +148,7 @@ def mock_marketplace_dir(tmp_path): # Create .plugin/marketplace.json plugin_dir = tmp_path / ".plugin" plugin_dir.mkdir() - + marketplace_data = { "name": "test-marketplace", "owner": {"name": "Test Owner"}, @@ -161,13 +158,13 @@ def mock_marketplace_dir(tmp_path): ], "skills": [], } - + (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) - + # Create plugin directories (tmp_path / "plugins" / "a").mkdir(parents=True) (tmp_path / "plugins" / "b").mkdir(parents=True) - + return tmp_path @@ -181,16 +178,14 @@ def test_resolve_plugin_from_specific_marketplace(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - + # Mock fetch to return the local path - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + source = registry.resolve_plugin("plugin-a@test") - + assert source.source == str(mock_marketplace_dir / "plugins" / "a") def test_resolve_plugin_not_found_in_marketplace(self, mock_marketplace_dir): @@ -200,26 +195,24 @@ def test_resolve_plugin_not_found_in_marketplace(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + with pytest.raises(PluginNotFoundError) as exc_info: registry.resolve_plugin("nonexistent@test") - + assert exc_info.value.plugin_name == "nonexistent" assert exc_info.value.marketplace_name == "test" def test_resolve_plugin_marketplace_not_registered(self): """Test error when referenced marketplace is not registered.""" registry = MarketplaceRegistry() - + with pytest.raises(MarketplaceNotFoundError) as exc_info: registry.resolve_plugin("plugin@unknown") - + assert exc_info.value.marketplace_name == "unknown" def test_resolve_plugin_search_all_marketplaces(self, mock_marketplace_dir): @@ -229,15 +222,13 @@ def test_resolve_plugin_search_all_marketplaces(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + source = registry.resolve_plugin("plugin-a") - + assert source.source == str(mock_marketplace_dir / "plugins" / "a") def test_resolve_plugin_not_found_anywhere(self, mock_marketplace_dir): @@ -247,16 +238,14 @@ def test_resolve_plugin_not_found_anywhere(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + with pytest.raises(PluginNotFoundError) as exc_info: registry.resolve_plugin("nonexistent") - + assert exc_info.value.plugin_name == "nonexistent" assert exc_info.value.marketplace_name is None @@ -268,7 +257,7 @@ def test_resolve_plugin_ambiguous(self, tmp_path): mp_dir.mkdir() plugin_dir = mp_dir / ".plugin" plugin_dir.mkdir() - + marketplace_data = { "name": name, "owner": {"name": "Owner"}, @@ -278,22 +267,24 @@ def test_resolve_plugin_ambiguous(self, tmp_path): } (plugin_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) (mp_dir / "plugins" / "common").mkdir(parents=True) - + regs = [ MarketplaceRegistration(name="mp1", source=str(tmp_path / "marketplace1")), MarketplaceRegistration(name="mp2", source=str(tmp_path / "marketplace2")), ] registry = MarketplaceRegistry(regs) - + def mock_fetch(reg): - mp_path = tmp_path / ("marketplace1" if reg.name == "mp1" else "marketplace2") + mp_path = tmp_path / ( + "marketplace1" if reg.name == "mp1" else "marketplace2" + ) marketplace = Marketplace.load(mp_path) return (marketplace, mp_path) - - with patch.object(registry, '_fetch_marketplace', side_effect=mock_fetch): + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): with pytest.raises(AmbiguousPluginError) as exc_info: registry.resolve_plugin("common-plugin") - + assert exc_info.value.plugin_name == "common-plugin" assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} @@ -304,15 +295,13 @@ def test_list_plugins_from_marketplace(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + plugins = registry.list_plugins("test") - + assert set(plugins) == {"plugin-a", "plugin-b"} def test_list_plugins_from_all(self, mock_marketplace_dir): @@ -322,13 +311,11 @@ def test_list_plugins_from_all(self, mock_marketplace_dir): source=str(mock_marketplace_dir), ) registry = MarketplaceRegistry([reg]) - - with patch.object( - registry, '_fetch_marketplace' - ) as mock_fetch: + + with patch.object(registry, "_fetch_marketplace") as mock_fetch: marketplace = Marketplace.load(mock_marketplace_dir) mock_fetch.return_value = (marketplace, mock_marketplace_dir) - + plugins = registry.list_plugins() - + assert set(plugins) == {"plugin-a", "plugin-b"} diff --git a/tests/sdk/plugin/test_marketplace_registry_integration.py b/tests/sdk/plugin/test_marketplace_registry_integration.py index 01265eec90..d2111a69d1 100644 --- a/tests/sdk/plugin/test_marketplace_registry_integration.py +++ b/tests/sdk/plugin/test_marketplace_registry_integration.py @@ -1,17 +1,16 @@ """Integration tests for MarketplaceRegistry - full registration and resolution flow.""" import json -import pytest from pathlib import Path +import pytest + from openhands.sdk.plugin import ( + AmbiguousPluginError, MarketplaceRegistration, MarketplaceRegistry, - PluginSource, Plugin, - Marketplace, PluginNotFoundError, - AmbiguousPluginError, ) @@ -22,44 +21,46 @@ def create_marketplace( skills: list[dict] | None = None, ) -> Path: """Helper to create a complete marketplace directory structure. - + Args: base_path: Parent directory for the marketplace name: Marketplace name - plugins: List of plugin definitions, each with 'name' and optionally 'description' + plugins: List of plugin definitions with 'name' and optional 'description' skills: Optional list of skill definitions - + Returns: Path to the marketplace directory """ marketplace_dir = base_path / name marketplace_dir.mkdir(parents=True, exist_ok=True) - + # Create .plugin/marketplace.json plugin_meta_dir = marketplace_dir / ".plugin" plugin_meta_dir.mkdir(exist_ok=True) - + # Build plugin entries with sources pointing to local directories plugin_entries = [] for plugin in plugins: plugin_name = plugin["name"] - plugin_entries.append({ - "name": plugin_name, - "source": f"./plugins/{plugin_name}", - "description": plugin.get("description", f"Plugin {plugin_name}"), - }) - + plugin_entries.append( + { + "name": plugin_name, + "source": f"./plugins/{plugin_name}", + "description": plugin.get("description", f"Plugin {plugin_name}"), + } + ) + # Create plugin directory with plugin.json plugin_dir = marketplace_dir / "plugins" / plugin_name / ".plugin" plugin_dir.mkdir(parents=True, exist_ok=True) - + plugin_manifest = { "name": plugin_name, "version": plugin.get("version", "1.0.0"), "description": plugin.get("description", f"Plugin {plugin_name}"), } (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest, indent=2)) - + # Create a sample skill in the plugin skills_dir = plugin_dir / "skills" skills_dir.mkdir(exist_ok=True) @@ -73,7 +74,7 @@ def create_marketplace( This is a skill provided by the {plugin_name} plugin. """ (skills_dir / "SKILL.md").write_text(skill_content) - + marketplace_data = { "name": name, "owner": {"name": "Test Owner", "email": "test@example.com"}, @@ -81,11 +82,11 @@ def create_marketplace( "plugins": plugin_entries, "skills": skills or [], } - + (plugin_meta_dir / "marketplace.json").write_text( json.dumps(marketplace_data, indent=2) ) - + return marketplace_dir @@ -103,28 +104,30 @@ def test_single_marketplace_registration_and_resolution(self, tmp_path): {"name": "linter", "description": "Code linter"}, ], ) - + # Register the marketplace - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="company", - source=str(marketplace_dir), - auto_load="all", - ), - ]) - + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="company", + source=str(marketplace_dir), + auto_load="all", + ), + ] + ) + # Verify registration assert "company" in registry.registrations assert registry.registrations["company"].auto_load == "all" - + # Resolve plugin with explicit marketplace source = registry.resolve_plugin("formatter@company") assert source.source == str(marketplace_dir / "plugins" / "formatter") - + # Resolve plugin without marketplace (search all) source = registry.resolve_plugin("linter") assert source.source == str(marketplace_dir / "plugins" / "linter") - + # List all plugins plugins = registry.list_plugins("company") assert set(plugins) == {"formatter", "linter"} @@ -140,7 +143,7 @@ def test_multiple_marketplace_registration(self, tmp_path): {"name": "docker", "description": "Docker utilities"}, ], ) - + team_dir = create_marketplace( tmp_path, name="team-marketplace", @@ -149,32 +152,34 @@ def test_multiple_marketplace_registration(self, tmp_path): {"name": "monitor", "description": "Monitoring tools"}, ], ) - + # Register both marketplaces - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="public", - source=str(public_dir), - auto_load="all", - ), - MarketplaceRegistration( - name="team", - source=str(team_dir), - auto_load="all", - ), - ]) - + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", + ), + MarketplaceRegistration( + name="team", + source=str(team_dir), + auto_load="all", + ), + ] + ) + # Resolve plugins from specific marketplaces git_source = registry.resolve_plugin("git@public") assert "public-marketplace" in git_source.source - + deploy_source = registry.resolve_plugin("deploy@team") assert "team-marketplace" in deploy_source.source - + # Resolve unique plugin without marketplace qualifier docker_source = registry.resolve_plugin("docker") assert "docker" in docker_source.source - + # List all plugins from all marketplaces all_plugins = registry.list_plugins() assert set(all_plugins) == {"git", "docker", "deploy", "monitor"} @@ -186,35 +191,37 @@ def test_auto_load_vs_registered_only(self, tmp_path): name="public", plugins=[{"name": "common"}], ) - + experimental_dir = create_marketplace( tmp_path, name="experimental", plugins=[{"name": "beta-tool"}], ) - - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="public", - source=str(public_dir), - auto_load="all", # Auto-load - ), - MarketplaceRegistration( - name="experimental", - source=str(experimental_dir), - # auto_load=None (default) - registered but not auto-loaded - ), - ]) - + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="public", + source=str(public_dir), + auto_load="all", # Auto-load + ), + MarketplaceRegistration( + name="experimental", + source=str(experimental_dir), + # auto_load=None (default) - registered but not auto-loaded + ), + ] + ) + # Check auto_load registrations auto_load_regs = registry.get_auto_load_registrations() assert len(auto_load_regs) == 1 assert auto_load_regs[0].name == "public" - + # Both marketplaces can still resolve plugins common_source = registry.resolve_plugin("common@public") assert common_source is not None - + beta_source = registry.resolve_plugin("beta-tool@experimental") assert beta_source is not None @@ -226,29 +233,31 @@ def test_ambiguous_plugin_error(self, tmp_path): name="marketplace1", plugins=[{"name": "shared-plugin", "description": "Version from MP1"}], ) - + mp2_dir = create_marketplace( tmp_path, name="marketplace2", plugins=[{"name": "shared-plugin", "description": "Version from MP2"}], ) - - registry = MarketplaceRegistry([ - MarketplaceRegistration(name="mp1", source=str(mp1_dir)), - MarketplaceRegistration(name="mp2", source=str(mp2_dir)), - ]) - + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ] + ) + # Resolving without qualifier should fail with pytest.raises(AmbiguousPluginError) as exc_info: registry.resolve_plugin("shared-plugin") - + assert exc_info.value.plugin_name == "shared-plugin" assert set(exc_info.value.matching_marketplaces) == {"mp1", "mp2"} - + # But explicit qualification should work source1 = registry.resolve_plugin("shared-plugin@mp1") assert "marketplace1" in source1.source - + source2 = registry.resolve_plugin("shared-plugin@mp2") assert "marketplace2" in source2.source @@ -259,17 +268,19 @@ def test_plugin_not_found_error(self, tmp_path): name="test-marketplace", plugins=[{"name": "existing-plugin"}], ) - - registry = MarketplaceRegistry([ - MarketplaceRegistration(name="test", source=str(marketplace_dir)), - ]) - + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="test", source=str(marketplace_dir)), + ] + ) + # Non-existent plugin in specific marketplace with pytest.raises(PluginNotFoundError) as exc_info: registry.resolve_plugin("nonexistent@test") assert exc_info.value.plugin_name == "nonexistent" assert exc_info.value.marketplace_name == "test" - + # Non-existent plugin searching all with pytest.raises(PluginNotFoundError) as exc_info: registry.resolve_plugin("nonexistent") @@ -283,20 +294,22 @@ def test_marketplace_caching(self, tmp_path): name="cached-marketplace", plugins=[{"name": "plugin-a"}, {"name": "plugin-b"}], ) - - registry = MarketplaceRegistry([ - MarketplaceRegistration(name="cached", source=str(marketplace_dir)), - ]) - + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="cached", source=str(marketplace_dir)), + ] + ) + # First resolution - should fetch and cache - source_a = registry.resolve_plugin("plugin-a@cached") - + registry.resolve_plugin("plugin-a@cached") + # Check that marketplace is now cached assert "cached" in registry._cache - + # Second resolution - should use cache - source_b = registry.resolve_plugin("plugin-b@cached") - + registry.resolve_plugin("plugin-b@cached") + # Cache should still have just one entry assert len(registry._cache) == 1 @@ -304,18 +317,20 @@ def test_prefetch_all_marketplaces(self, tmp_path): """Test eagerly prefetching all registered marketplaces.""" mp1_dir = create_marketplace(tmp_path, "mp1", [{"name": "p1"}]) mp2_dir = create_marketplace(tmp_path, "mp2", [{"name": "p2"}]) - - registry = MarketplaceRegistry([ - MarketplaceRegistration(name="mp1", source=str(mp1_dir)), - MarketplaceRegistration(name="mp2", source=str(mp2_dir)), - ]) - + + registry = MarketplaceRegistry( + [ + MarketplaceRegistration(name="mp1", source=str(mp1_dir)), + MarketplaceRegistration(name="mp2", source=str(mp2_dir)), + ] + ) + # Cache should be empty initially assert len(registry._cache) == 0 - + # Prefetch all registry.prefetch_all() - + # Both should now be cached assert len(registry._cache) == 2 assert "mp1" in registry._cache @@ -326,22 +341,22 @@ def test_monorepo_marketplace_with_repo_path(self, tmp_path): # Create a monorepo structure monorepo_dir = tmp_path / "monorepo" monorepo_dir.mkdir() - + # Create marketplace in a subdirectory marketplace_subdir = monorepo_dir / "packages" / "marketplace" marketplace_subdir.mkdir(parents=True) - + # Create .plugin structure in the subdirectory plugin_meta_dir = marketplace_subdir / ".plugin" plugin_meta_dir.mkdir() - + # Create plugin directory plugin_dir = marketplace_subdir / "plugins" / "monorepo-plugin" / ".plugin" plugin_dir.mkdir(parents=True) - + plugin_manifest = {"name": "monorepo-plugin", "version": "1.0.0"} (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) - + marketplace_data = { "name": "monorepo-marketplace", "owner": {"name": "Test"}, @@ -350,17 +365,19 @@ def test_monorepo_marketplace_with_repo_path(self, tmp_path): ], } (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) - + # Register with repo_path pointing to the subdirectory # Note: For local paths, repo_path isn't used the same way as git repos # The source should point directly to the marketplace directory - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="monorepo", - source=str(marketplace_subdir), - ), - ]) - + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="monorepo", + source=str(marketplace_subdir), + ), + ] + ) + # Should be able to resolve the plugin source = registry.resolve_plugin("monorepo-plugin@monorepo") assert "monorepo-plugin" in source.source @@ -370,11 +387,11 @@ def test_full_plugin_load_flow(self, tmp_path): # Create a marketplace directory manually with proper plugin structure marketplace_dir = tmp_path / "full-test-marketplace" marketplace_dir.mkdir() - + # Create marketplace manifest mp_meta_dir = marketplace_dir / ".plugin" mp_meta_dir.mkdir() - + marketplace_data = { "name": "full-test-marketplace", "owner": {"name": "Test"}, @@ -383,20 +400,24 @@ def test_full_plugin_load_flow(self, tmp_path): ], } (mp_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) - + # Create plugin with skills directory at root level (not inside .plugin) plugin_dir = marketplace_dir / "plugins" / "test-plugin" plugin_dir.mkdir(parents=True) - + # Plugin manifest in .plugin/ plugin_meta_dir = plugin_dir / ".plugin" plugin_meta_dir.mkdir() - (plugin_meta_dir / "plugin.json").write_text(json.dumps({ - "name": "test-plugin", - "version": "1.0.0", - "description": "A complete test plugin", - })) - + (plugin_meta_dir / "plugin.json").write_text( + json.dumps( + { + "name": "test-plugin", + "version": "1.0.0", + "description": "A complete test plugin", + } + ) + ) + # Skills directory at plugin root level skills_dir = plugin_dir / "skills" skills_dir.mkdir() @@ -410,26 +431,28 @@ def test_full_plugin_load_flow(self, tmp_path): This is a skill provided by the test-plugin plugin. """ (skills_dir / "SKILL.md").write_text(skill_content) - + # Register the marketplace - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="fulltest", - source=str(marketplace_dir), - auto_load="all", - ), - ]) - + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="fulltest", + source=str(marketplace_dir), + auto_load="all", + ), + ] + ) + # Resolve the plugin plugin_source = registry.resolve_plugin("test-plugin@fulltest") - + # Load the plugin using the resolved source plugin = Plugin.load(plugin_source.source) - + # Verify plugin was loaded correctly assert plugin.manifest.name == "test-plugin" assert plugin.manifest.description == "A complete test plugin" - + # Check that skills were loaded assert len(plugin.skills) > 0 assert any("test-plugin" in s.name for s in plugin.skills) @@ -438,18 +461,18 @@ def test_marketplace_with_claude_plugin_directory(self, tmp_path): """Test marketplace using .claude-plugin directory (fallback).""" marketplace_dir = tmp_path / "claude-compat-marketplace" marketplace_dir.mkdir() - + # Use .claude-plugin instead of .plugin plugin_meta_dir = marketplace_dir / ".claude-plugin" plugin_meta_dir.mkdir() - + # Create plugin plugin_dir = marketplace_dir / "plugins" / "claude-plugin" / ".claude-plugin" plugin_dir.mkdir(parents=True) - + plugin_manifest = {"name": "claude-plugin", "version": "1.0.0"} (plugin_dir / "plugin.json").write_text(json.dumps(plugin_manifest)) - + marketplace_data = { "name": "claude-compat", "owner": {"name": "Test"}, @@ -458,15 +481,17 @@ def test_marketplace_with_claude_plugin_directory(self, tmp_path): ], } (plugin_meta_dir / "marketplace.json").write_text(json.dumps(marketplace_data)) - + # Register and resolve - registry = MarketplaceRegistry([ - MarketplaceRegistration( - name="claude", - source=str(marketplace_dir), - ), - ]) - + registry = MarketplaceRegistry( + [ + MarketplaceRegistration( + name="claude", + source=str(marketplace_dir), + ), + ] + ) + source = registry.resolve_plugin("claude-plugin@claude") assert "claude-plugin" in source.source @@ -478,17 +503,17 @@ def test_load_plugin_from_marketplace(self, tmp_path): """Test loading a plugin via conversation.load_plugin().""" from openhands.sdk import LLM, Agent, AgentContext from openhands.sdk.conversation import Conversation - + # Create a marketplace with a plugin marketplace_dir = create_marketplace( tmp_path, name="test-marketplace", plugins=[{"name": "test-plugin", "description": "A test plugin"}], ) - + # Create agent with registered marketplace (use dummy LLM - won't make calls) llm = LLM(model="test/model", api_key="test-key") - + agent_context = AgentContext( registered_marketplaces=[ MarketplaceRegistration( @@ -497,76 +522,77 @@ def test_load_plugin_from_marketplace(self, tmp_path): ), ], ) - + agent = Agent( llm=llm, tools=[], agent_context=agent_context, ) - + # Create conversation workspace_dir = tmp_path / "workspace" workspace_dir.mkdir() - + conversation = Conversation( agent=agent, workspace=str(workspace_dir), ) - + # Verify no resolved_plugins yet assert conversation.resolved_plugins is None - + # Load the plugin conversation.load_plugin("test-plugin@test") - + # Verify resolved_plugins was updated assert conversation.resolved_plugins is not None assert len(conversation.resolved_plugins) == 1 - assert conversation.resolved_plugins[0].source == f"{marketplace_dir}/plugins/test-plugin" - + expected_source = f"{marketplace_dir}/plugins/test-plugin" + assert conversation.resolved_plugins[0].source == expected_source + conversation.close() def test_load_plugin_no_marketplaces_registered(self, tmp_path): """Test that load_plugin raises ValueError when no marketplaces registered.""" from openhands.sdk import LLM, Agent, AgentContext from openhands.sdk.conversation import Conversation - + # Create agent without registered marketplaces llm = LLM(model="test/model", api_key="test-key") - + agent = Agent( llm=llm, tools=[], agent_context=AgentContext(), ) - + workspace_dir = tmp_path / "workspace" workspace_dir.mkdir() - + conversation = Conversation( agent=agent, workspace=str(workspace_dir), ) - + with pytest.raises(ValueError, match="No marketplaces registered"): conversation.load_plugin("some-plugin") - + conversation.close() def test_load_plugin_not_found(self, tmp_path): """Test that load_plugin raises PluginNotFoundError for missing plugins.""" from openhands.sdk import LLM, Agent, AgentContext from openhands.sdk.conversation import Conversation - + # Create a marketplace with a plugin marketplace_dir = create_marketplace( tmp_path, name="test-marketplace", plugins=[{"name": "existing-plugin"}], ) - + llm = LLM(model="test/model", api_key="test-key") - + agent_context = AgentContext( registered_marketplaces=[ MarketplaceRegistration( @@ -575,22 +601,22 @@ def test_load_plugin_not_found(self, tmp_path): ), ], ) - + agent = Agent( llm=llm, tools=[], agent_context=agent_context, ) - + workspace_dir = tmp_path / "workspace" workspace_dir.mkdir() - + conversation = Conversation( agent=agent, workspace=str(workspace_dir), ) - + with pytest.raises(PluginNotFoundError): conversation.load_plugin("nonexistent-plugin@test") - + conversation.close() diff --git a/uv.lock b/uv.lock index 36a5c8beb1..8b38087a1b 100644 --- a/uv.lock +++ b/uv.lock @@ -2341,7 +2341,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2372,7 +2372,7 @@ requires-dist = [ [[package]] name = "openhands-sdk" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2416,7 +2416,7 @@ provides-extras = ["boto3"] [[package]] name = "openhands-tools" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-tools" } dependencies = [ { name = "bashlex" }, @@ -2445,7 +2445,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" }, From 8ac50728a6905cd054ea5784f467295482bf9445 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 00:02:47 +0000 Subject: [PATCH 09/20] fix: Revert version bump - version changes are handled by release workflow The API breakage check detecting the deprecation of marketplace_path is expected. Version bump will be applied through the Prepare Release workflow. --- openhands-agent-server/pyproject.toml | 2 +- openhands-sdk/pyproject.toml | 2 +- openhands-tools/pyproject.toml | 2 +- openhands-workspace/pyproject.toml | 2 +- uv.lock | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 5d5b2ea6c0..c7573f4b6f 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 6eec53bf9b..248921c6e2 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.15.0" +version = "1.14.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 410ad98638..3677f4c6f6 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index ce2747250b..1d4a314d64 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 8b38087a1b..36a5c8beb1 100644 --- a/uv.lock +++ b/uv.lock @@ -2341,7 +2341,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2372,7 +2372,7 @@ requires-dist = [ [[package]] name = "openhands-sdk" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2416,7 +2416,7 @@ provides-extras = ["boto3"] [[package]] name = "openhands-tools" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-tools" } dependencies = [ { name = "bashlex" }, @@ -2445,7 +2445,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" }, From 976a9ab53a0baeb1e40b1d882bb5768d9a6a5b19 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 00:19:47 +0000 Subject: [PATCH 10/20] refactor: Simplify marketplace example with pre-created files - Remove dynamic file creation from main.py - Add pre-created demo_marketplace/ directory with: - .plugin/marketplace.json - plugins/greeter/.plugin/plugin.json - plugins/greeter/skills/SKILL.md - Remove .gitignore that excluded demo_marketplace/ This makes the example cleaner and easier to understand. --- .../.gitignore | 1 - .../demo_marketplace/.plugin/marketplace.json | 6 +++ .../plugins/greeter/.plugin/plugin.json | 5 ++ .../plugins/greeter/skills/SKILL.md | 6 +++ .../main.py | 50 ++----------------- 5 files changed, 22 insertions(+), 46 deletions(-) delete mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore deleted file mode 100644 index 71a5c11e64..0000000000 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/.gitignore +++ /dev/null @@ -1 +0,0 @@ -demo_marketplace/ diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json new file mode 100644 index 0000000000..7283b8c401 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/.plugin/marketplace.json @@ -0,0 +1,6 @@ +{ + "name": "demo-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], + "skills": [] +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json new file mode 100644 index 0000000000..f21faea2a6 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "greeter", + "version": "1.0.0", + "description": "A greeting plugin" +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md new file mode 100644 index 0000000000..cbeb08b370 --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/demo_marketplace/plugins/greeter/skills/SKILL.md @@ -0,0 +1,6 @@ +--- +name: greeter-skill +description: Generates friendly greetings +--- +# Greeter Skill +When asked to greet someone, respond with a warm, friendly greeting. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 87afdd0955..fc3a8ad13b 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -4,9 +4,10 @@ - auto_load="all": Load all plugins at conversation start - auto_load=None: Register but don't auto-load (use conversation.load_plugin()) + +This example uses a pre-created marketplace in ./demo_marketplace/ """ -import json import os from pathlib import Path @@ -17,50 +18,9 @@ SCRIPT_DIR = Path(__file__).parent -def create_example_marketplace() -> Path: - """Create a simple marketplace with a plugin for this demo.""" - marketplace_dir = SCRIPT_DIR / "demo_marketplace" - plugin_dir = marketplace_dir / "plugins" / "greeter" - - # Create marketplace manifest - (marketplace_dir / ".plugin").mkdir(parents=True, exist_ok=True) - (marketplace_dir / ".plugin" / "marketplace.json").write_text( - json.dumps( - { - "name": "demo-marketplace", - "owner": {"name": "Demo"}, - "plugins": [{"name": "greeter", "source": "./plugins/greeter"}], - "skills": [], - } - ) - ) - - # Create plugin with a skill - (plugin_dir / ".plugin").mkdir(parents=True, exist_ok=True) - (plugin_dir / ".plugin" / "plugin.json").write_text( - json.dumps( - { - "name": "greeter", - "version": "1.0.0", - "description": "A greeting plugin", - } - ) - ) - - (plugin_dir / "skills").mkdir(exist_ok=True) - (plugin_dir / "skills" / "SKILL.md").write_text("""--- -name: greeter-skill -description: Generates friendly greetings ---- -# Greeter Skill -When asked to greet someone, respond with a warm, friendly greeting. -""") - - return marketplace_dir - - def main(): - marketplace_dir = create_example_marketplace() + # Use pre-created marketplace in this directory + marketplace_dir = SCRIPT_DIR / "demo_marketplace" llm = LLM( model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), @@ -74,7 +34,7 @@ def main(): MarketplaceRegistration( name="demo", source=str(marketplace_dir), - # auto_load=None - we'll load explicitly + # auto_load=None means we load explicitly with load_plugin() ), ], ) From 1f6d823511884567a318da92145f0f614a5bfb84 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 00:25:02 +0000 Subject: [PATCH 11/20] feat: Add auto-load marketplace to example Demonstrates both loading strategies in one example: - auto_marketplace/ with auto_load='all' - loaded at conversation start - demo_marketplace/ with auto_load=None - loaded on-demand This shows the full power of multiple marketplace registrations. --- .../auto_marketplace/.plugin/marketplace.json | 6 +++ .../plugins/helper/.plugin/plugin.json | 5 +++ .../plugins/helper/skills/SKILL.md | 6 +++ .../main.py | 38 ++++++++++++------- 4 files changed, 41 insertions(+), 14 deletions(-) create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json create mode 100644 examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json new file mode 100644 index 0000000000..f77b951d0b --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/.plugin/marketplace.json @@ -0,0 +1,6 @@ +{ + "name": "auto-marketplace", + "owner": {"name": "Demo"}, + "plugins": [{"name": "helper", "source": "./plugins/helper"}], + "skills": [] +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json new file mode 100644 index 0000000000..973dc3aa5f --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/.plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "helper", + "version": "1.0.0", + "description": "A helper plugin with utility skills" +} diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md new file mode 100644 index 0000000000..9871e44e9f --- /dev/null +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/auto_marketplace/plugins/helper/skills/SKILL.md @@ -0,0 +1,6 @@ +--- +name: helper-skill +description: Provides helpful utilities and tips +--- +# Helper Skill +When asked for help or tips, provide clear and useful guidance. diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index fc3a8ad13b..6397b1aa14 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -1,11 +1,13 @@ """Example: Multiple Marketplace Registrations -Register multiple marketplaces and load plugins on-demand. +Demonstrates two loading strategies for marketplace plugins: -- auto_load="all": Load all plugins at conversation start -- auto_load=None: Register but don't auto-load (use conversation.load_plugin()) +- auto_load="all": Plugins loaded automatically at conversation start +- auto_load=None: Plugins loaded on-demand via conversation.load_plugin() -This example uses a pre-created marketplace in ./demo_marketplace/ +This example uses pre-created marketplaces in: +- ./auto_marketplace/ - auto-loaded at conversation start +- ./demo_marketplace/ - loaded on-demand """ import os @@ -19,22 +21,26 @@ def main(): - # Use pre-created marketplace in this directory - marketplace_dir = SCRIPT_DIR / "demo_marketplace" - llm = LLM( model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL"), ) - # Register marketplace (not auto-loaded) + # Register two marketplaces with different loading strategies agent_context = AgentContext( registered_marketplaces=[ + # Auto-loaded: plugins available immediately when conversation starts + MarketplaceRegistration( + name="auto", + source=str(SCRIPT_DIR / "auto_marketplace"), + auto_load="all", + ), + # On-demand: registered but not loaded until explicitly requested MarketplaceRegistration( name="demo", - source=str(marketplace_dir), - # auto_load=None means we load explicitly with load_plugin() + source=str(SCRIPT_DIR / "demo_marketplace"), + # auto_load=None (default) - use load_plugin() to load ), ], ) @@ -42,14 +48,18 @@ def main(): agent = Agent(llm=llm, tools=[], agent_context=agent_context) conversation = Conversation(agent=agent, workspace=os.getcwd()) - # Load the plugin on-demand + # The "auto" marketplace plugins are already loaded + # Now load an additional plugin on-demand from "demo" marketplace conversation.load_plugin("greeter@demo") + resolved = conversation.resolved_plugins if resolved: - print(f"Loaded: {resolved[0].source}") + print(f"Loaded {len(resolved)} plugin(s):") + for plugin in resolved: + print(f" - {plugin.source}") - # Use the skill - conversation.send_message("Please greet me!") + # Use skills from both plugins + conversation.send_message("Give me a tip, then greet me!") conversation.run() print(f"\nEXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") From bb94316c4a77e17ca3bb7cdfe1ac093c35a0d6c1 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 00:39:16 +0000 Subject: [PATCH 12/20] docs: Reference Claude Code plugin syntax in example The plugin-name@marketplace-name format is consistent with Claude Code's plugin install syntax. --- .../04_multiple_marketplace_registrations/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py index 6397b1aa14..a25eafedd0 100644 --- a/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py +++ b/examples/05_skills_and_plugins/04_multiple_marketplace_registrations/main.py @@ -50,6 +50,7 @@ def main(): # The "auto" marketplace plugins are already loaded # Now load an additional plugin on-demand from "demo" marketplace + # Format: "plugin-name@marketplace-name" (same as Claude Code plugin syntax) conversation.load_plugin("greeter@demo") resolved = conversation.resolved_plugins From 4664c63cbbe76e8241ec542a22245ccdd3b39919 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 01:18:01 +0000 Subject: [PATCH 13/20] fix: address code review feedback for multiple marketplace registrations - Fix silent exception swallowing in registry.py: - _resolve_from_all() now accumulates fetch errors and includes them in PluginNotFoundError when all marketplaces fail - list_plugins() now raises PluginResolutionError with details when all fail - Improves debugging: users see actual errors instead of 'plugin not found' - Simplify agent_context.py deprecation logic: - Remove unnecessary effective_marketplace_path variable - Remove misleading comment about backward compatibility - Improve path validation security in types.py: - Add _validate_repo_path() helper using os.path.normpath() - Catches path traversal tricks like 'safe/../../../etc' - Both MarketplaceRegistration and PluginSource use shared validation - Fix state mutation atomicity in local_conversation.py: - Update both self.agent and self._state.agent within same lock context - Add comprehensive tests: - TestErrorAccumulation: 3 tests for error accumulation behavior - TestPathValidation: 5 tests for path security validation --- .../openhands/sdk/context/agent_context.py | 9 +- .../conversation/impl/local_conversation.py | 10 +- .../openhands/sdk/plugin/registry.py | 40 +++++- openhands-sdk/openhands/sdk/plugin/types.py | 56 +++++--- tests/sdk/plugin/test_marketplace_registry.py | 134 +++++++++++++++++- 5 files changed, 214 insertions(+), 35 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index 30f0242647..f7f4f00890 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -135,10 +135,7 @@ def _load_auto_skills(self): if not self.load_user_skills and not self.load_public_skills: return self - # Handle backward compatibility: if marketplace_path is set but - # registered_marketplaces is empty, emit deprecation warning and - # convert to a default marketplace registration - effective_marketplace_path = self.marketplace_path + # Emit deprecation warning if using old marketplace_path field if self.marketplace_path is not None and not self.registered_marketplaces: warnings.warn( "AgentContext.marketplace_path is deprecated. " @@ -146,15 +143,13 @@ def _load_auto_skills(self): DeprecationWarning, stacklevel=2, ) - # For backward compatibility, we still use marketplace_path - # when registered_marketplaces is empty auto_skills = load_available_skills( work_dir=None, include_user=self.load_user_skills, include_project=False, include_public=self.load_public_skills, - marketplace_path=effective_marketplace_path, + marketplace_path=self.marketplace_path, ) existing_names = {skill.name for skill in self.skills} diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 4368685e70..b52765ac6b 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1188,17 +1188,17 @@ def load_plugin(self, plugin_ref: str) -> None: dict(self.agent.mcp_config) if self.agent.mcp_config else {} ) - # Update agent with merged content - self.agent = self.agent.model_copy( + # Update agent and state atomically + # Create new agent first, then update both references together + new_agent = self.agent.model_copy( update={ "agent_context": merged_context, "mcp_config": merged_mcp, } ) - - # Update agent in state for API observability with self._state: - self._state.agent = self.agent + self.agent = new_agent + self._state.agent = new_agent # Track resolved plugin resolved = ResolvedPluginSource.from_plugin_source( diff --git a/openhands-sdk/openhands/sdk/plugin/registry.py b/openhands-sdk/openhands/sdk/plugin/registry.py index 50af6ebde0..20f39e5439 100644 --- a/openhands-sdk/openhands/sdk/plugin/registry.py +++ b/openhands-sdk/openhands/sdk/plugin/registry.py @@ -38,13 +38,29 @@ def __init__(self, plugin_name: str, matching_marketplaces: list[str]): class PluginNotFoundError(PluginResolutionError): """Raised when a plugin cannot be found in any registered marketplace.""" - def __init__(self, plugin_name: str, marketplace_name: str | None = None): + def __init__( + self, + plugin_name: str, + marketplace_name: str | None = None, + fetch_errors: dict[str, Exception] | None = None, + ): self.plugin_name = plugin_name self.marketplace_name = marketplace_name + self.fetch_errors = fetch_errors or {} + if marketplace_name: msg = ( f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'" ) + elif fetch_errors: + # All marketplaces failed to fetch - show the actual errors + error_details = "; ".join( + f"'{name}': {err}" for name, err in fetch_errors.items() + ) + msg = ( + f"Plugin '{plugin_name}' not found. " + f"All {len(fetch_errors)} marketplace(s) failed to fetch: {error_details}" + ) else: msg = f"Plugin '{plugin_name}' not found in any registered marketplace" super().__init__(msg) @@ -226,10 +242,13 @@ def _resolve_from_marketplace( def _resolve_from_all(self, plugin_name: str) -> PluginSource: """Resolve a plugin by searching all registered marketplaces.""" matches: list[tuple[str, PluginSource]] = [] + fetch_errors: dict[str, Exception] = {} + searched_count = 0 for name, reg in self._registrations.items(): try: marketplace, repo_path = self._fetch_marketplace(reg) + searched_count += 1 plugin_entry = marketplace.get_plugin(plugin_name) if plugin_entry is not None: @@ -244,12 +263,16 @@ def _resolve_from_all(self, plugin_name: str) -> PluginSource: matches.append((name, plugin_source)) except Exception as e: + fetch_errors[name] = e logger.warning( f"Error searching marketplace '{name}' " f"for plugin '{plugin_name}': {e}" ) if not matches: + # If all marketplaces failed to fetch, include errors in exception + if fetch_errors and searched_count == 0: + raise PluginNotFoundError(plugin_name, fetch_errors=fetch_errors) raise PluginNotFoundError(plugin_name) if len(matches) > 1: @@ -269,6 +292,9 @@ def list_plugins(self, marketplace_name: str | None = None) -> list[str]: Returns: List of plugin names (may include duplicates if searching all). + + Raises: + PluginResolutionError: If all marketplaces fail to fetch when listing all. """ plugin_names: list[str] = [] @@ -276,11 +302,23 @@ def list_plugins(self, marketplace_name: str | None = None) -> list[str]: marketplace, _ = self.get_marketplace(marketplace_name) plugin_names.extend(p.name for p in marketplace.plugins) else: + fetch_errors: dict[str, Exception] = {} for name, reg in self._registrations.items(): try: marketplace, _ = self._fetch_marketplace(reg) plugin_names.extend(p.name for p in marketplace.plugins) except Exception as e: + fetch_errors[name] = e logger.warning(f"Error listing plugins from '{name}': {e}") + # If all marketplaces failed, raise with details + if fetch_errors and not plugin_names and self._registrations: + error_details = "; ".join( + f"'{name}': {err}" for name, err in fetch_errors.items() + ) + raise PluginResolutionError( + f"Failed to list plugins. " + f"All {len(fetch_errors)} marketplace(s) failed: {error_details}" + ) + return plugin_names diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index 9f85679d89..70b8525e2b 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -74,18 +75,41 @@ class MarketplaceRegistration(BaseModel): @classmethod def validate_repo_path(cls, v: str | None) -> str | None: """Validate repo_path is a safe relative path within the repository.""" - if v is None: - return v - # Must be relative (no absolute paths) - if v.startswith("/"): - raise ValueError("repo_path must be relative, not absolute") - # No parent directory traversal - if ".." in Path(v).parts: - raise ValueError( - "repo_path cannot contain '..' (parent directory traversal)" - ) + return _validate_repo_path(v) + + +def _validate_repo_path(v: str | None) -> str | None: + """Validate that a repo_path is a safe relative path. + + Ensures the path: + - Is relative (not absolute) + - Does not escape the repository root via '..' traversal + - Normalizes to a path within the repo even after resolution + """ + if v is None: return v + # Must be relative (no absolute paths) + if v.startswith("/"): + raise ValueError("repo_path must be relative, not absolute") + + # Normalize the path to catch tricks like 'safe/../../../etc' + # Use a dummy root to resolve against, then check if result stays inside + dummy_root = Path("/repo") + normalized = os.path.normpath(os.path.join(str(dummy_root), v)) + normalized_path = Path(normalized) + + # Check if the normalized path is still under dummy_root + # This catches paths like 'a/../../etc' which normalize to '/etc' + try: + normalized_path.relative_to(dummy_root) + except ValueError: + raise ValueError( + f"repo_path '{v}' escapes repository root after normalization" + ) + + return v + class PluginSource(BaseModel): """Specification for a plugin to load. @@ -127,17 +151,7 @@ class PluginSource(BaseModel): @classmethod def validate_repo_path(cls, v: str | None) -> str | None: """Validate repo_path is a safe relative path within the repository.""" - if v is None: - return v - # Must be relative (no absolute paths) - if v.startswith("/"): - raise ValueError("repo_path must be relative, not absolute") - # No parent directory traversal - if ".." in Path(v).parts: - raise ValueError( - "repo_path cannot contain '..' (parent directory traversal)" - ) - return v + return _validate_repo_path(v) class ResolvedPluginSource(BaseModel): diff --git a/tests/sdk/plugin/test_marketplace_registry.py b/tests/sdk/plugin/test_marketplace_registry.py index 12dc0869e1..52405216c0 100644 --- a/tests/sdk/plugin/test_marketplace_registry.py +++ b/tests/sdk/plugin/test_marketplace_registry.py @@ -12,6 +12,7 @@ MarketplaceRegistration, MarketplaceRegistry, PluginNotFoundError, + PluginResolutionError, ) @@ -68,7 +69,7 @@ def test_repo_path_validation_rejects_absolute(self): def test_repo_path_validation_rejects_traversal(self): """Test that parent directory traversal is rejected.""" - with pytest.raises(ValueError, match="cannot contain '..'"): + with pytest.raises(ValueError, match="escapes repository root"): MarketplaceRegistration( name="test", source="github:owner/repo", @@ -319,3 +320,134 @@ def test_list_plugins_from_all(self, mock_marketplace_dir): plugins = registry.list_plugins() assert set(plugins) == {"plugin-a", "plugin-b"} + + +class TestErrorAccumulation: + """Tests for error accumulation when marketplaces fail.""" + + def test_resolve_plugin_all_marketplaces_fail_shows_errors(self): + """Test that when all marketplaces fail, errors are included in exception.""" + regs = [ + MarketplaceRegistration(name="mp1", source="github:owner/repo1"), + MarketplaceRegistration(name="mp2", source="github:owner/repo2"), + ] + registry = MarketplaceRegistry(regs) + + # Mock _fetch_marketplace to always fail + error1 = ConnectionError("Network unreachable for mp1") + error2 = TimeoutError("Timeout connecting to mp2") + + def mock_fetch(reg): + if reg.name == "mp1": + raise error1 + raise error2 + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("some-plugin") + + # Error should mention all marketplace failures + assert exc_info.value.fetch_errors is not None + assert len(exc_info.value.fetch_errors) == 2 + assert "mp1" in exc_info.value.fetch_errors + assert "mp2" in exc_info.value.fetch_errors + # Exception message should contain details + assert "All 2 marketplace(s) failed" in str(exc_info.value) + assert "Network unreachable" in str(exc_info.value) + assert "Timeout" in str(exc_info.value) + + def test_resolve_plugin_partial_failures_dont_show_errors( + self, mock_marketplace_dir + ): + """Test that partial failures (some succeed) don't include fetch_errors.""" + regs = [ + MarketplaceRegistration(name="failing", source="github:owner/bad"), + MarketplaceRegistration(name="working", source=str(mock_marketplace_dir)), + ] + registry = MarketplaceRegistry(regs) + + marketplace = Marketplace.load(mock_marketplace_dir) + + def mock_fetch(reg): + if reg.name == "failing": + raise ConnectionError("Network error") + return (marketplace, mock_marketplace_dir) + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + # Plugin not in the marketplace that succeeded + with pytest.raises(PluginNotFoundError) as exc_info: + registry.resolve_plugin("nonexistent-plugin") + + # Since one marketplace was searched successfully, we get normal error + assert "not found in any registered marketplace" in str(exc_info.value) + # fetch_errors should be empty (not all failed) + assert not exc_info.value.fetch_errors + + def test_list_plugins_all_marketplaces_fail_raises_error(self): + """Test that list_plugins raises error with details when all fail.""" + regs = [ + MarketplaceRegistration(name="mp1", source="github:owner/repo1"), + MarketplaceRegistration(name="mp2", source="github:owner/repo2"), + ] + registry = MarketplaceRegistry(regs) + + def mock_fetch(reg): + raise ConnectionError(f"Failed to fetch {reg.name}") + + with patch.object(registry, "_fetch_marketplace", side_effect=mock_fetch): + with pytest.raises(PluginResolutionError) as exc_info: + registry.list_plugins() + + # Error message should show all failures + assert "All 2 marketplace(s) failed" in str(exc_info.value) + assert "mp1" in str(exc_info.value) + assert "mp2" in str(exc_info.value) + + +class TestPathValidation: + """Tests for repo_path validation security.""" + + def test_repo_path_rejects_traversal_via_normalization(self): + """Test that paths like 'safe/../../../etc' are rejected.""" + with pytest.raises(ValueError, match="escapes repository root"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="safe/../../../etc/passwd", + ) + + def test_repo_path_allows_valid_nested_path(self): + """Test that valid nested paths are allowed.""" + reg = MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="marketplaces/internal/plugins", + ) + assert reg.repo_path == "marketplaces/internal/plugins" + + def test_repo_path_allows_simple_path(self): + """Test that simple paths without any tricks work.""" + reg = MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="plugins", + ) + assert reg.repo_path == "plugins" + + def test_repo_path_rejects_absolute_path(self): + """Test that absolute paths are rejected.""" + with pytest.raises(ValueError, match="must be relative"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="/etc/passwd", + ) + + def test_repo_path_rejects_simple_parent_traversal(self): + """Test that simple '..' traversal is rejected.""" + with pytest.raises(ValueError, match="escapes repository root"): + MarketplaceRegistration( + name="test", + source="github:owner/repo", + repo_path="../outside", + ) From 964a9e91d13f6919faf5651552d456a53f56b3fc Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 01:30:34 +0000 Subject: [PATCH 14/20] fix: resolve ruff lint and format issues - Fix E501 line too long in registry.py (90 > 88) - Fix ruff format issue in types.py (collapse multi-line string) Co-authored-by: openhands --- openhands-sdk/openhands/sdk/plugin/registry.py | 3 ++- openhands-sdk/openhands/sdk/plugin/types.py | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/plugin/registry.py b/openhands-sdk/openhands/sdk/plugin/registry.py index 20f39e5439..972d74377c 100644 --- a/openhands-sdk/openhands/sdk/plugin/registry.py +++ b/openhands-sdk/openhands/sdk/plugin/registry.py @@ -59,7 +59,8 @@ def __init__( ) msg = ( f"Plugin '{plugin_name}' not found. " - f"All {len(fetch_errors)} marketplace(s) failed to fetch: {error_details}" + f"All {len(fetch_errors)} marketplace(s) failed to fetch: " + f"{error_details}" ) else: msg = f"Plugin '{plugin_name}' not found in any registered marketplace" diff --git a/openhands-sdk/openhands/sdk/plugin/types.py b/openhands-sdk/openhands/sdk/plugin/types.py index 70b8525e2b..ba9daf8418 100644 --- a/openhands-sdk/openhands/sdk/plugin/types.py +++ b/openhands-sdk/openhands/sdk/plugin/types.py @@ -104,9 +104,7 @@ def _validate_repo_path(v: str | None) -> str | None: try: normalized_path.relative_to(dummy_root) except ValueError: - raise ValueError( - f"repo_path '{v}' escapes repository root after normalization" - ) + raise ValueError(f"repo_path '{v}' escapes repository root after normalization") return v From 4052e87447ac19157b99b2529791f5b8657e18c6 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 22:00:42 +0000 Subject: [PATCH 15/20] feat: Wire auto_load='all' into startup and deprecate remaining APIs This commit implements Option 2 from the review discussion, completing the full migration path for registered_marketplaces: LocalConversation changes: - _ensure_plugins_loaded now honors registered_marketplaces with auto_load='all' - Automatically loads all plugins from marketplaces marked for auto-loading - Plugins from explicit specs load first, then marketplace auto-load plugins Agent-server changes: - Add SkillsRequest.registered_marketplaces field - Deprecate SkillsRequest.marketplace_path with warning - Update load_all_skills() to accept registered_marketplaces parameter - Add _load_marketplace_skills() helper to load skills from auto-load marketplaces - Marketplace-loaded skills have highest precedence in merge order SDK deprecations: - load_public_skills(marketplace_path=...) now emits DeprecationWarning - load_available_skills(marketplace_path=...) now emits DeprecationWarning - Both docstrings updated to indicate deprecation This fully implements the 4 deprecations outlined in issue #2494: 1. AgentContext.marketplace_path -> registered_marketplaces (done in original PR) 2. load_public_skills(marketplace_path=...) -> marketplace registry (this commit) 3. load_available_skills(marketplace_path=...) -> marketplace registry (this commit) 4. SkillsRequest.marketplace_path -> registered_marketplaces (this commit) Co-authored-by: openhands --- .../openhands/agent_server/skills_router.py | 36 +++++++- .../openhands/agent_server/skills_service.py | 88 ++++++++++++++++++- .../openhands/sdk/context/skills/skill.py | 47 ++++++++-- .../conversation/impl/local_conversation.py | 68 +++++++++++--- 4 files changed, 219 insertions(+), 20 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/skills_router.py b/openhands-agent-server/openhands/agent_server/skills_router.py index b01c21b893..7ebddedb52 100644 --- a/openhands-agent-server/openhands/agent_server/skills_router.py +++ b/openhands-agent-server/openhands/agent_server/skills_router.py @@ -4,6 +4,7 @@ Business logic is delegated to skills_service.py. """ +import warnings from typing import Literal from fastapi import APIRouter @@ -15,6 +16,7 @@ sync_public_skills, ) from openhands.sdk.context.skills.skill import DEFAULT_MARKETPLACE_PATH +from openhands.sdk.plugin.types import MarketplaceRegistration skills_router = APIRouter(prefix="/skills", tags=["Skills"]) @@ -66,11 +68,22 @@ class SkillsRequest(BaseModel): load_org: bool = Field(default=True, description="Load organization-level skills") marketplace_path: str | None = Field( default=DEFAULT_MARKETPLACE_PATH, + deprecated=True, description=( + "DEPRECATED: Use registered_marketplaces instead. " "Relative marketplace JSON path for public skills. " "Set to null to load all public skills." ), ) + registered_marketplaces: list[MarketplaceRegistration] = Field( + default_factory=list, + description=( + "List of marketplace registrations for skill loading. " + "Each registration specifies a marketplace source and whether to " + "auto-load its skills. When provided, this takes precedence over " + "marketplace_path for public skill filtering." + ), + ) project_dir: str | None = Field( default=None, description="Workspace directory path for project skills" ) @@ -143,6 +156,26 @@ def get_skills(request: SkillsRequest) -> SkillsResponse: org_repo_url = request.org_config.org_repo_url org_name = request.org_config.org_name + # Handle deprecation: prefer registered_marketplaces over marketplace_path + marketplace_path = request.marketplace_path + if request.registered_marketplaces: + # New behavior: use registered_marketplaces + # For now, we extract marketplace_path from the first 'public' registration + # with auto_load='all' for backward compatibility with the existing service + for reg in request.registered_marketplaces: + if reg.name == "public" and reg.auto_load == "all": + # Use repo_path as marketplace_path if set + marketplace_path = reg.repo_path + break + elif request.marketplace_path is not None: + # Emit deprecation warning when using old field + warnings.warn( + "SkillsRequest.marketplace_path is deprecated. " + "Use registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + # Call the service result = load_all_skills( load_public=request.load_public, @@ -153,7 +186,8 @@ def get_skills(request: SkillsRequest) -> SkillsResponse: org_repo_url=org_repo_url, org_name=org_name, sandbox_exposed_urls=sandbox_urls, - marketplace_path=request.marketplace_path, + marketplace_path=marketplace_path, + registered_marketplaces=request.registered_marketplaces, ) # Convert Skill objects to SkillInfo for response diff --git a/openhands-agent-server/openhands/agent_server/skills_service.py b/openhands-agent-server/openhands/agent_server/skills_service.py index 68bc390512..a4227e4a49 100644 --- a/openhands-agent-server/openhands/agent_server/skills_service.py +++ b/openhands-agent-server/openhands/agent_server/skills_service.py @@ -9,16 +9,21 @@ - Project skills: {workspace}/.openhands/skills/, .cursorrules, agents.md - Organization skills: {org}/.openhands or {org}/openhands-config - Sandbox skills: Exposed URLs from sandbox environment +- Registered marketplaces: Multiple marketplace sources with auto-load support Precedence (later overrides earlier): -sandbox < public < user < org < project +sandbox < public < user < org < project < marketplace-plugins """ +from __future__ import annotations + import shutil import subprocess import tempfile +import warnings from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING from openhands.sdk.context.skills import ( Skill, @@ -38,6 +43,10 @@ from openhands.sdk.utils import sanitized_env +if TYPE_CHECKING: + from openhands.sdk.plugin.types import MarketplaceRegistration + + logger = get_logger(__name__) @@ -286,6 +295,7 @@ def load_all_skills( org_name: str | None = None, sandbox_exposed_urls: list[ExposedUrlData] | None = None, marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH, + registered_marketplaces: list["MarketplaceRegistration"] | None = None, ) -> SkillLoadResult: """Load and merge skills from all configured sources. @@ -296,6 +306,7 @@ def load_all_skills( 3. User skills - From ~/.openhands/skills/ 4. Organization skills - From {org}/.openhands or equivalent 5. Project skills (highest) - From {workspace}/.openhands/skills/ + 6. Marketplace plugins (highest) - From registered marketplaces with auto_load Args: load_public: Whether to load public skills from OpenHands/extensions repo. @@ -306,8 +317,10 @@ def load_all_skills( org_repo_url: Pre-authenticated Git URL for org skills. org_name: Organization name for org skills. sandbox_exposed_urls: List of exposed URLs from sandbox. - marketplace_path: Relative marketplace JSON path for public skills. + marketplace_path: DEPRECATED - Relative marketplace JSON path for public skills. Pass None to load all public skills without marketplace filtering. + registered_marketplaces: List of marketplace registrations. When provided, + marketplaces with auto_load='all' will have their skills loaded. Returns: SkillLoadResult containing merged skills and source counts. @@ -349,7 +362,7 @@ def load_all_skills( sources["org"] = len(org_skills) skill_lists.append(org_skills) - # 5. Load project skills (highest precedence) + # 5. Load project skills project_skills = load_available_skills( work_dir=project_dir if load_project else None, include_user=False, @@ -359,6 +372,13 @@ def load_all_skills( sources["project"] = len(project_skills) skill_lists.append(list(project_skills.values())) + # 6. Load skills from registered marketplaces with auto_load='all' (highest) + marketplace_skills: list[Skill] = [] + if registered_marketplaces: + marketplace_skills = _load_marketplace_skills(registered_marketplaces) + sources["marketplace"] = len(marketplace_skills) + skill_lists.append(marketplace_skills) + # Merge all skills with precedence all_skills = merge_skills(skill_lists) @@ -369,6 +389,68 @@ def load_all_skills( return SkillLoadResult(skills=all_skills, sources=sources) +def _load_marketplace_skills( + registrations: list["MarketplaceRegistration"], +) -> list[Skill]: + """Load skills from registered marketplaces with auto_load='all'. + + This function iterates through marketplace registrations, fetches those + marked for auto-loading, and loads all their plugin skills. + + Args: + registrations: List of marketplace registrations. + + Returns: + List of skills loaded from auto-load marketplaces. + """ + from openhands.sdk.plugin import MarketplaceRegistry, Plugin, fetch_plugin_with_resolution + + all_skills: list[Skill] = [] + + # Create registry and get auto-load registrations + registry = MarketplaceRegistry(registrations) + auto_load_regs = registry.get_auto_load_registrations() + + for reg in auto_load_regs: + try: + marketplace, repo_path = registry.get_marketplace(reg.name) + logger.info( + f"Loading skills from {len(marketplace.plugins)} plugin(s) in " + f"marketplace '{reg.name}'" + ) + + for plugin_entry in marketplace.plugins: + try: + # Resolve and fetch the plugin + source, ref, subpath = marketplace.resolve_plugin_source(plugin_entry) + path, resolved_ref = fetch_plugin_with_resolution( + source=source, + ref=ref, + repo_path=subpath, + ) + + # Load the plugin and extract skills + plugin = Plugin.load(path) + if plugin.skills: + all_skills.extend(plugin.skills) + logger.debug( + f"Loaded {len(plugin.skills)} skill(s) from plugin " + f"'{plugin.manifest.name}' in marketplace '{reg.name}'" + ) + except Exception as e: + logger.warning( + f"Failed to load plugin '{plugin_entry.name}' " + f"from marketplace '{reg.name}': {e}" + ) + + except Exception as e: + logger.warning( + f"Failed to load marketplace '{reg.name}': {e}" + ) + + return all_skills + + def sync_public_skills() -> tuple[bool, str]: """Force refresh of public skills from GitHub repository. diff --git a/openhands-sdk/openhands/sdk/context/skills/skill.py b/openhands-sdk/openhands/sdk/context/skills/skill.py index 7ab95f0351..6c14575d51 100644 --- a/openhands-sdk/openhands/sdk/context/skills/skill.py +++ b/openhands-sdk/openhands/sdk/context/skills/skill.py @@ -1,6 +1,7 @@ import io import json import re +import warnings from pathlib import Path from typing import Annotated, ClassVar, Literal, Union from xml.sax.saxutils import escape as xml_escape @@ -929,6 +930,11 @@ def load_public_skills( ) -> list[Skill]: """Load skills from the public OpenHands skills repository. + .. deprecated:: + The `marketplace_path` parameter is deprecated. Use + `AgentContext.registered_marketplaces` with `MarketplaceRegistration` + for more flexible marketplace configuration. + This function maintains a local git clone of the public skills registry at https://github.com/OpenHands/extensions. On first run, it clones the repository to ~/.openhands/skills-cache/. On subsequent runs, it pulls the latest changes @@ -948,8 +954,9 @@ def load_public_skills( repo_url: URL of the skills repository. Defaults to the official OpenHands skills repository. branch: Branch name to load skills from. Defaults to 'main'. - marketplace_path: Relative path to the marketplace JSON file within the - repository. Pass None to load all public skills without filtering. + marketplace_path: DEPRECATED - Relative path to the marketplace JSON file + within the repository. Pass None to load all public skills without + filtering. Use registered_marketplaces instead. Returns: List of Skill objects loaded from the public repository. @@ -965,6 +972,15 @@ def load_public_skills( >>> # Use with AgentContext >>> context = AgentContext(skills=public_skills) """ + # Emit deprecation warning when marketplace_path is explicitly provided + if marketplace_path is not None: + warnings.warn( + "load_public_skills(marketplace_path=...) is deprecated. " + "Use AgentContext.registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + all_skills = [] try: @@ -1061,6 +1077,11 @@ def load_available_skills( ) -> dict[str, Skill]: """Load and merge skills from SDK-level sources with consistent precedence. + .. deprecated:: + The `marketplace_path` parameter is deprecated. Use + `AgentContext.registered_marketplaces` with `MarketplaceRegistration` + for more flexible marketplace configuration. + Precedence (later overrides earlier via dict updates): public (lowest) → user → project (highest) @@ -1074,19 +1095,33 @@ def load_available_skills( 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. - marketplace_path: Relative marketplace JSON path to use for public skills. - Pass None to load all public skills without marketplace filtering. + marketplace_path: DEPRECATED - Relative marketplace JSON path to use for + public skills. Pass None to load all public skills without marketplace + filtering. Use registered_marketplaces instead. Returns: Dict mapping skill name → Skill, with higher-precedence sources overriding lower ones. """ + # Emit deprecation warning when marketplace_path is explicitly provided + if marketplace_path is not None: + warnings.warn( + "load_available_skills(marketplace_path=...) is deprecated. " + "Use AgentContext.registered_marketplaces instead.", + DeprecationWarning, + stacklevel=2, + ) + available: dict[str, Skill] = {} if include_public: try: - for s in load_public_skills(marketplace_path=marketplace_path): - available[s.name] = s + # Suppress the nested deprecation warning from load_public_skills + # since we already warned above + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + for s in load_public_skills(marketplace_path=marketplace_path): + available[s.name] = s except Exception as e: logger.warning(f"Failed to load public skills: {e}") diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index b52765ac6b..4b0e6cf1c6 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -314,11 +314,12 @@ def _ensure_plugins_loaded(self) -> None: The method: 1. Fetches plugins from their sources (network IO for remote sources) - 2. Resolves refs to commit SHAs for deterministic resume - 3. Loads plugin contents (skills, MCP config, hooks) - 4. Merges plugin contents into the agent - 5. Sets up hook processor with combined hooks (explicit + plugin) - 6. Runs session_start hooks + 2. Auto-loads plugins from registered marketplaces with auto_load='all' + 3. Resolves refs to commit SHAs for deterministic resume + 4. Loads plugin contents (skills, MCP config, hooks) + 5. Merges plugin contents into the agent + 6. Sets up hook processor with combined hooks (explicit + plugin) + 7. Runs session_start hooks """ if self._plugins_loaded: return @@ -326,16 +327,63 @@ def _ensure_plugins_loaded(self) -> None: all_plugin_hooks: list[HookConfig] = [] all_plugin_agents: list[AgentDefinition] = [] - # Load plugins if specified + # Collect plugins from both explicit specs and auto-load marketplaces + plugins_to_load: list[tuple[PluginSource, str]] = [] # (spec, source_desc) + + # Add explicit plugin specs first if self._plugin_specs: - logger.info(f"Loading {len(self._plugin_specs)} plugin(s)...") + for spec in self._plugin_specs: + plugins_to_load.append((spec, f"explicit: {spec.source}")) + + # Auto-load plugins from registered marketplaces with auto_load='all' + agent_context = self.agent.agent_context + if agent_context and agent_context.registered_marketplaces: + # Initialize marketplace registry if needed + if self._marketplace_registry is None: + self._marketplace_registry = MarketplaceRegistry( + agent_context.registered_marketplaces + ) + + # Get marketplaces that should auto-load + auto_load_registrations = ( + self._marketplace_registry.get_auto_load_registrations() + ) + for reg in auto_load_registrations: + try: + marketplace, repo_path = self._marketplace_registry.get_marketplace( + reg.name + ) + logger.info( + f"Auto-loading {len(marketplace.plugins)} plugin(s) from " + f"marketplace '{reg.name}'" + ) + for plugin_entry in marketplace.plugins: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + plugin_source = PluginSource( + source=source, + ref=ref, + repo_path=subpath, + ) + plugins_to_load.append( + (plugin_source, f"marketplace:{reg.name}/{plugin_entry.name}") + ) + except Exception as e: + logger.warning( + f"Failed to auto-load plugins from marketplace '{reg.name}': {e}" + ) + + # Load all collected plugins + if plugins_to_load: + logger.info(f"Loading {len(plugins_to_load)} plugin(s)...") self._resolved_plugins = [] # Start with agent's existing context and MCP config merged_context = self.agent.agent_context merged_mcp = dict(self.agent.mcp_config) if self.agent.mcp_config else {} - for spec in self._plugin_specs: + for spec, source_desc in plugins_to_load: # Fetch plugin and get resolved commit SHA path, resolved_ref = fetch_plugin_with_resolution( source=spec.source, @@ -350,7 +398,7 @@ def _ensure_plugins_loaded(self) -> None: # Load the plugin plugin = Plugin.load(path) logger.debug( - f"Loaded plugin '{plugin.manifest.name}' from {spec.source}" + f"Loaded plugin '{plugin.manifest.name}' from {source_desc}" + (f" @ {resolved_ref[:8]}" if resolved_ref else "") ) @@ -378,7 +426,7 @@ def _ensure_plugins_loaded(self) -> None: with self._state: self._state.agent = self.agent - logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation") + logger.info(f"Loaded {len(plugins_to_load)} plugin(s) via Conversation") # Register file-based agents defined in plugins if all_plugin_agents: From 56011bad4ad460de0aa8f20f7d27b388d8b985dd Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 20 Mar 2026 00:26:44 +0000 Subject: [PATCH 16/20] fix: Line length violation in local_conversation.py Co-authored-by: openhands --- .../openhands/sdk/conversation/impl/local_conversation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 4b0e6cf1c6..a3138f5b8f 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -371,7 +371,8 @@ def _ensure_plugins_loaded(self) -> None: ) except Exception as e: logger.warning( - f"Failed to auto-load plugins from marketplace '{reg.name}': {e}" + f"Failed to auto-load plugins from marketplace " + f"'{reg.name}': {e}" ) # Load all collected plugins From abb8b9abfa6ddd42c09d663bd2949d163a7b44b4 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 20 Mar 2026 00:33:15 +0000 Subject: [PATCH 17/20] style: Apply ruff formatting fixes Co-authored-by: openhands --- .../openhands/agent_server/skills_service.py | 19 +++++++++++-------- .../conversation/impl/local_conversation.py | 5 ++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/skills_service.py b/openhands-agent-server/openhands/agent_server/skills_service.py index a4227e4a49..0998192817 100644 --- a/openhands-agent-server/openhands/agent_server/skills_service.py +++ b/openhands-agent-server/openhands/agent_server/skills_service.py @@ -20,7 +20,6 @@ import shutil import subprocess import tempfile -import warnings from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -295,7 +294,7 @@ def load_all_skills( org_name: str | None = None, sandbox_exposed_urls: list[ExposedUrlData] | None = None, marketplace_path: str | None = DEFAULT_MARKETPLACE_PATH, - registered_marketplaces: list["MarketplaceRegistration"] | None = None, + registered_marketplaces: list[MarketplaceRegistration] | None = None, ) -> SkillLoadResult: """Load and merge skills from all configured sources. @@ -390,7 +389,7 @@ def load_all_skills( def _load_marketplace_skills( - registrations: list["MarketplaceRegistration"], + registrations: list[MarketplaceRegistration], ) -> list[Skill]: """Load skills from registered marketplaces with auto_load='all'. @@ -403,7 +402,11 @@ def _load_marketplace_skills( Returns: List of skills loaded from auto-load marketplaces. """ - from openhands.sdk.plugin import MarketplaceRegistry, Plugin, fetch_plugin_with_resolution + from openhands.sdk.plugin import ( + MarketplaceRegistry, + Plugin, + fetch_plugin_with_resolution, + ) all_skills: list[Skill] = [] @@ -422,7 +425,9 @@ def _load_marketplace_skills( for plugin_entry in marketplace.plugins: try: # Resolve and fetch the plugin - source, ref, subpath = marketplace.resolve_plugin_source(plugin_entry) + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) path, resolved_ref = fetch_plugin_with_resolution( source=source, ref=ref, @@ -444,9 +449,7 @@ def _load_marketplace_skills( ) except Exception as e: - logger.warning( - f"Failed to load marketplace '{reg.name}': {e}" - ) + logger.warning(f"Failed to load marketplace '{reg.name}': {e}") return all_skills diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index a3138f5b8f..7dfbe5d08d 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -367,7 +367,10 @@ def _ensure_plugins_loaded(self) -> None: repo_path=subpath, ) plugins_to_load.append( - (plugin_source, f"marketplace:{reg.name}/{plugin_entry.name}") + ( + plugin_source, + f"marketplace:{reg.name}/{plugin_entry.name}", + ) ) except Exception as e: logger.warning( From 2bfa3f5e3d71cec7673f6a2af0dc1a6e7a60ced1 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 28 Mar 2026 18:34:37 +0000 Subject: [PATCH 18/20] fix: Complete registered_marketplaces as source of truth for skill loading Address remaining reviewer feedback from PR review: Gap 1: AgentContext._load_auto_skills() now uses registered_marketplaces - When registered_marketplaces is set, skills are loaded from marketplaces with auto_load='all' via _load_skills_from_marketplaces() - User skills still loaded via legacy path (they are local files) - Legacy marketplace_path path only used when registered_marketplaces is empty - This makes registered_marketplaces the true source of truth at the AgentContext construction level, not just at conversation startup Gap 3: load_plugin() now fully applies plugin semantics - Merges plugin hooks into existing hook processor via _merge_plugin_hooks() - Registers plugin agent definitions via register_plugin_agents() - Skills and MCP config merging was already working - This matches what _ensure_plugins_loaded() does for startup plugins The implementation now honors the full plugin contract: - Skills are merged into agent_context - MCP config is merged into agent.mcp_config - Hooks are merged into the hook processor - Agent definitions are registered in the agent registry Co-authored-by: openhands --- .../openhands/sdk/context/agent_context.py | 106 +++++++++++++++++- .../conversation/impl/local_conversation.py | 64 ++++++++++- 2 files changed, 164 insertions(+), 6 deletions(-) diff --git a/openhands-sdk/openhands/sdk/context/agent_context.py b/openhands-sdk/openhands/sdk/context/agent_context.py index f7f4f00890..116902f447 100644 --- a/openhands-sdk/openhands/sdk/context/agent_context.py +++ b/openhands-sdk/openhands/sdk/context/agent_context.py @@ -4,6 +4,7 @@ import warnings from collections.abc import Mapping from datetime import datetime +from typing import TYPE_CHECKING from pydantic import BaseModel, Field, field_validator, model_validator @@ -22,6 +23,9 @@ from openhands.sdk.secret import SecretSource, SecretValue +if TYPE_CHECKING: + pass + logger = get_logger(__name__) # Constants for default marketplace @@ -131,12 +135,56 @@ def _validate_skills(cls, v: list[Skill], _info): @model_validator(mode="after") def _load_auto_skills(self): - """Load user and/or public skills if enabled.""" + """Load user and/or public skills if enabled. + + When `registered_marketplaces` is set, skills are loaded from marketplaces + with `auto_load='all'`. This is the new, preferred approach. + + When only `marketplace_path` is set (deprecated), falls back to the legacy + `load_available_skills` path for backward compatibility. + """ if not self.load_user_skills and not self.load_public_skills: return self - # Emit deprecation warning if using old marketplace_path field - if self.marketplace_path is not None and not self.registered_marketplaces: + existing_names = {skill.name for skill in self.skills} + + # New path: use registered_marketplaces as source of truth + if self.registered_marketplaces: + # Load user skills via legacy path if requested (user skills are local) + if self.load_user_skills: + user_skills = load_available_skills( + work_dir=None, + include_user=True, + include_project=False, + include_public=False, + marketplace_path=None, + ) + for name, skill in user_skills.items(): + if name not in existing_names: + self.skills.append(skill) + existing_names.add(name) + else: + logger.warning( + f"Skipping user skill '{name}' (already in explicit skills)" + ) + + # Load public skills from registered marketplaces with auto_load='all' + if self.load_public_skills: + marketplace_skills = self._load_skills_from_marketplaces() + for skill in marketplace_skills: + if skill.name not in existing_names: + self.skills.append(skill) + existing_names.add(skill.name) + else: + logger.warning( + f"Skipping marketplace skill '{skill.name}' " + "(already in explicit skills)" + ) + + return self + + # Legacy path: use marketplace_path (deprecated) + if self.marketplace_path is not None: warnings.warn( "AgentContext.marketplace_path is deprecated. " "Use registered_marketplaces instead.", @@ -152,7 +200,6 @@ def _load_auto_skills(self): marketplace_path=self.marketplace_path, ) - 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) @@ -163,6 +210,57 @@ def _load_auto_skills(self): return self + def _load_skills_from_marketplaces(self) -> list[Skill]: + """Load skills from registered marketplaces with auto_load='all'. + + Returns: + List of skills loaded from auto-load marketplaces. + """ + # Import at runtime to avoid circular import + from openhands.sdk.plugin import ( + MarketplaceRegistry, + Plugin, + fetch_plugin_with_resolution, + ) + + skills: list[Skill] = [] + registry = MarketplaceRegistry(self.registered_marketplaces) + + for reg in registry.get_auto_load_registrations(): + try: + marketplace, repo_path = registry.get_marketplace(reg.name) + logger.info( + f"Loading skills from marketplace '{reg.name}' " + f"({len(marketplace.plugins)} plugins)" + ) + + for plugin_entry in marketplace.plugins: + try: + source, ref, subpath = marketplace.resolve_plugin_source( + plugin_entry + ) + path, resolved_ref = fetch_plugin_with_resolution( + source=source, + ref=ref, + repo_path=subpath, + ) + plugin = Plugin.load(path) + plugin_skills = plugin.get_all_skills() + skills.extend(plugin_skills) + logger.debug( + f"Loaded {len(plugin_skills)} skill(s) from plugin " + f"'{plugin_entry.name}'" + ) + except Exception as e: + logger.warning( + f"Failed to load plugin '{plugin_entry.name}' " + f"from marketplace '{reg.name}': {e}" + ) + except Exception as e: + logger.warning(f"Failed to fetch marketplace '{reg.name}': {e}") + + return skills + def get_secret_infos(self) -> list[dict[str, str | None]]: """Get secret information (name and description) from the secrets field. diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 7dfbe5d08d..18f5d6ec0a 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1184,7 +1184,8 @@ def load_plugin(self, plugin_ref: str) -> None: Resolves the plugin reference against the agent's registered marketplaces and loads the plugin into the conversation. The plugin's skills, hooks, - and MCP configuration will be merged into the agent. + MCP configuration, and agent definitions will be merged into the + conversation. Plugin references can be: - "plugin-name@marketplace-name" - Explicit marketplace qualifier @@ -1199,7 +1200,7 @@ def load_plugin(self, plugin_ref: str) -> None: MarketplaceNotFoundError: If a specified marketplace is not registered. ValueError: If no marketplaces are registered. """ - # Ensure plugins loaded first (initializes agent context) + # Ensure plugins loaded first (initializes agent context and hook processor) self._ensure_plugins_loaded() # Lazy-initialize the marketplace registry from agent_context @@ -1252,6 +1253,22 @@ def load_plugin(self, plugin_ref: str) -> None: self.agent = new_agent self._state.agent = new_agent + # Register plugin agents if any + if plugin.agents: + register_plugin_agents( + agents=plugin.agents, + work_dir=self.workspace.working_dir, + ) + logger.debug( + f"Registered {len(plugin.agents)} agent(s) from plugin " + f"'{plugin.manifest.name}'" + ) + + # Merge plugin hooks into existing hook processor + if plugin.hooks and not plugin.hooks.is_empty(): + self._merge_plugin_hooks(plugin.hooks) + logger.debug(f"Merged hooks from plugin '{plugin.manifest.name}'") + # Track resolved plugin resolved = ResolvedPluginSource.from_plugin_source( resolved_source, resolved_ref @@ -1260,6 +1277,49 @@ def load_plugin(self, plugin_ref: str) -> None: self._resolved_plugins = [] self._resolved_plugins.append(resolved) + def _merge_plugin_hooks(self, plugin_hooks: HookConfig) -> None: + """Merge plugin hooks into the existing hook processor. + + If no hook processor exists, creates one with the plugin hooks. + If a hook processor exists, merges the new hooks (plugin hooks + run after existing hooks). + + Args: + plugin_hooks: HookConfig from the loaded plugin. + """ + if self._hook_processor is None: + # No existing hook processor - create one with plugin hooks + self._hook_processor, self._on_event = create_hook_callback( + hook_config=plugin_hooks, + working_dir=str(self.workspace.working_dir), + session_id=str(self._state.id), + original_callback=self._base_callback, + ) + self._hook_processor.set_conversation_state(self._state) + # Note: We don't run session_start here since the session is + # already started. The plugin's session_start hooks will miss + # the initial session start, which is expected for dynamically + # loaded plugins. + self._state.hook_config = plugin_hooks + else: + # Merge with existing hooks + existing_config = self._state.hook_config + if existing_config is not None: + merged_config = HookConfig.merge([existing_config, plugin_hooks]) + else: + merged_config = plugin_hooks + + if merged_config is not None: + # Recreate hook processor with merged config + self._hook_processor, self._on_event = create_hook_callback( + hook_config=merged_config, + working_dir=str(self.workspace.working_dir), + session_id=str(self._state.id), + original_callback=self._base_callback, + ) + self._hook_processor.set_conversation_state(self._state) + self._state.hook_config = merged_config + def __del__(self) -> None: """Ensure cleanup happens when conversation is destroyed.""" try: From 270226b44e4576f57f0ef181ed9c08af6e467f91 Mon Sep 17 00:00:00 2001 From: enyst Date: Sun, 5 Apr 2026 15:35:14 +0000 Subject: [PATCH 19/20] fix(sdk): reinitialize tools after runtime plugin load Rebuild the copied agent's runtime tool state when load_plugin() runs after conversation startup so new MCP tools become available immediately. Add a regression test covering marketplace-driven plugin loads after agent initialization. Co-authored-by: openhands --- .../conversation/impl/local_conversation.py | 11 ++- .../test_local_conversation_plugins.py | 96 ++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index cf9ad8c3f8..4b5d9901ef 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1261,14 +1261,23 @@ def load_plugin(self, plugin_ref: str) -> None: ) # Update agent and state atomically - # Create new agent first, then update both references together + # Create new agent first, then update both references together. + # If the conversation is already initialized, rebuild the copied + # agent's runtime tool state so newly added MCP tools become available. + was_agent_ready = self._agent_ready new_agent = self.agent.model_copy( update={ "agent_context": merged_context, "mcp_config": merged_mcp, } ) + if was_agent_ready: + new_agent._initialized = False + new_agent._tools = {} + with self._state: + if was_agent_ready: + new_agent.init_state(self._state, on_event=self._on_event) self.agent = new_agent self._state.agent = new_agent diff --git a/tests/sdk/conversation/test_local_conversation_plugins.py b/tests/sdk/conversation/test_local_conversation_plugins.py index e7ae709fe4..db0fcbbdd1 100644 --- a/tests/sdk/conversation/test_local_conversation_plugins.py +++ b/tests/sdk/conversation/test_local_conversation_plugins.py @@ -6,11 +6,11 @@ import pytest from pydantic import SecretStr -from openhands.sdk import LLM, Agent, Conversation +from openhands.sdk import LLM, Agent, AgentContext, Conversation from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.hooks import HookConfig from openhands.sdk.hooks.config import HookDefinition, HookMatcher -from openhands.sdk.plugin import PluginSource +from openhands.sdk.plugin import MarketplaceRegistration, PluginSource @pytest.fixture @@ -67,6 +67,39 @@ def create_test_plugin( return plugin_dir +def create_test_marketplace(marketplace_dir: Path, plugins: list[dict]) -> Path: + """Helper to create a test marketplace directory.""" + metadata_dir = marketplace_dir / ".plugin" + metadata_dir.mkdir(parents=True, exist_ok=True) + + plugin_entries = [] + for plugin in plugins: + plugin_name = plugin["name"] + create_test_plugin( + marketplace_dir / "plugins" / plugin_name, + name=plugin_name, + skills=plugin.get("skills"), + mcp_config=plugin.get("mcp_config"), + hooks=plugin.get("hooks"), + ) + plugin_entries.append( + { + "name": plugin_name, + "source": f"./plugins/{plugin_name}", + "description": plugin.get("description", f"Test plugin {plugin_name}"), + } + ) + + marketplace_manifest = { + "name": marketplace_dir.name, + "owner": {"name": "Test Owner", "email": "test@example.com"}, + "description": f"Test marketplace {marketplace_dir.name}", + "plugins": plugin_entries, + } + (metadata_dir / "marketplace.json").write_text(json.dumps(marketplace_manifest)) + return marketplace_dir + + class TestLocalConversationPlugins: """Tests for plugin loading in LocalConversation. @@ -276,6 +309,65 @@ def mock_create_mcp_tools(config, timeout): conversation.close() + def test_load_plugin_reinitializes_mcp_tools_after_startup( + self, tmp_path: Path, mock_llm, monkeypatch + ): + """Test runtime plugin loading reinitializes MCP tools after startup.""" + mcp_tools_created = [] + + def mock_create_mcp_tools(config, timeout): + mcp_tools_created.append(config) + return [] + + import openhands.sdk.agent.base + + monkeypatch.setattr( + openhands.sdk.agent.base, "create_mcp_tools", mock_create_mcp_tools + ) + + marketplace_dir = create_test_marketplace( + tmp_path / "marketplace", + plugins=[ + { + "name": "runtime-plugin", + "mcp_config": { + "mcpServers": {"runtime-server": {"command": "test-cmd"}} + }, + } + ], + ) + workspace = tmp_path / "workspace" + workspace.mkdir() + + conversation = LocalConversation( + agent=Agent( + llm=mock_llm, + tools=[], + agent_context=AgentContext( + registered_marketplaces=[ + MarketplaceRegistration( + name="test", + source=str(marketplace_dir), + ) + ] + ), + ), + workspace=workspace, + visualizer=None, + ) + + conversation._ensure_agent_ready() + assert mcp_tools_created == [] + + conversation.load_plugin("runtime-plugin@test") + + assert conversation.agent.mcp_config is not None + assert "runtime-server" in conversation.agent.mcp_config["mcpServers"] + assert len(mcp_tools_created) == 1 + assert "runtime-server" in mcp_tools_created[0]["mcpServers"] + + conversation.close() + class TestConversationFactoryPlugins: """Tests for plugin loading via Conversation factory. From 46174855f21efafce8391e345566d5de30f61588 Mon Sep 17 00:00:00 2001 From: enyst Date: Sun, 5 Apr 2026 15:40:54 +0000 Subject: [PATCH 20/20] revert: runtime plugin reload reinitialization fix Revert 270226b4 so the proposed fix remains visible in history for the PR author to evaluate. Co-authored-by: openhands --- .../conversation/impl/local_conversation.py | 11 +-- .../test_local_conversation_plugins.py | 96 +------------------ 2 files changed, 3 insertions(+), 104 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 4b5d9901ef..cf9ad8c3f8 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1261,23 +1261,14 @@ def load_plugin(self, plugin_ref: str) -> None: ) # Update agent and state atomically - # Create new agent first, then update both references together. - # If the conversation is already initialized, rebuild the copied - # agent's runtime tool state so newly added MCP tools become available. - was_agent_ready = self._agent_ready + # Create new agent first, then update both references together new_agent = self.agent.model_copy( update={ "agent_context": merged_context, "mcp_config": merged_mcp, } ) - if was_agent_ready: - new_agent._initialized = False - new_agent._tools = {} - with self._state: - if was_agent_ready: - new_agent.init_state(self._state, on_event=self._on_event) self.agent = new_agent self._state.agent = new_agent diff --git a/tests/sdk/conversation/test_local_conversation_plugins.py b/tests/sdk/conversation/test_local_conversation_plugins.py index db0fcbbdd1..e7ae709fe4 100644 --- a/tests/sdk/conversation/test_local_conversation_plugins.py +++ b/tests/sdk/conversation/test_local_conversation_plugins.py @@ -6,11 +6,11 @@ import pytest from pydantic import SecretStr -from openhands.sdk import LLM, Agent, AgentContext, Conversation +from openhands.sdk import LLM, Agent, Conversation from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.hooks import HookConfig from openhands.sdk.hooks.config import HookDefinition, HookMatcher -from openhands.sdk.plugin import MarketplaceRegistration, PluginSource +from openhands.sdk.plugin import PluginSource @pytest.fixture @@ -67,39 +67,6 @@ def create_test_plugin( return plugin_dir -def create_test_marketplace(marketplace_dir: Path, plugins: list[dict]) -> Path: - """Helper to create a test marketplace directory.""" - metadata_dir = marketplace_dir / ".plugin" - metadata_dir.mkdir(parents=True, exist_ok=True) - - plugin_entries = [] - for plugin in plugins: - plugin_name = plugin["name"] - create_test_plugin( - marketplace_dir / "plugins" / plugin_name, - name=plugin_name, - skills=plugin.get("skills"), - mcp_config=plugin.get("mcp_config"), - hooks=plugin.get("hooks"), - ) - plugin_entries.append( - { - "name": plugin_name, - "source": f"./plugins/{plugin_name}", - "description": plugin.get("description", f"Test plugin {plugin_name}"), - } - ) - - marketplace_manifest = { - "name": marketplace_dir.name, - "owner": {"name": "Test Owner", "email": "test@example.com"}, - "description": f"Test marketplace {marketplace_dir.name}", - "plugins": plugin_entries, - } - (metadata_dir / "marketplace.json").write_text(json.dumps(marketplace_manifest)) - return marketplace_dir - - class TestLocalConversationPlugins: """Tests for plugin loading in LocalConversation. @@ -309,65 +276,6 @@ def mock_create_mcp_tools(config, timeout): conversation.close() - def test_load_plugin_reinitializes_mcp_tools_after_startup( - self, tmp_path: Path, mock_llm, monkeypatch - ): - """Test runtime plugin loading reinitializes MCP tools after startup.""" - mcp_tools_created = [] - - def mock_create_mcp_tools(config, timeout): - mcp_tools_created.append(config) - return [] - - import openhands.sdk.agent.base - - monkeypatch.setattr( - openhands.sdk.agent.base, "create_mcp_tools", mock_create_mcp_tools - ) - - marketplace_dir = create_test_marketplace( - tmp_path / "marketplace", - plugins=[ - { - "name": "runtime-plugin", - "mcp_config": { - "mcpServers": {"runtime-server": {"command": "test-cmd"}} - }, - } - ], - ) - workspace = tmp_path / "workspace" - workspace.mkdir() - - conversation = LocalConversation( - agent=Agent( - llm=mock_llm, - tools=[], - agent_context=AgentContext( - registered_marketplaces=[ - MarketplaceRegistration( - name="test", - source=str(marketplace_dir), - ) - ] - ), - ), - workspace=workspace, - visualizer=None, - ) - - conversation._ensure_agent_ready() - assert mcp_tools_created == [] - - conversation.load_plugin("runtime-plugin@test") - - assert conversation.agent.mcp_config is not None - assert "runtime-server" in conversation.agent.mcp_config["mcpServers"] - assert len(mcp_tools_created) == 1 - assert "runtime-server" in mcp_tools_created[0]["mcpServers"] - - conversation.close() - class TestConversationFactoryPlugins: """Tests for plugin loading via Conversation factory.