Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e2a27c4
feat(plugin): Add MarketplaceRegistration and MarketplaceRegistry for…
openhands-agent Mar 18, 2026
084e527
test(plugin): Add integration tests for marketplace registration and …
openhands-agent Mar 18, 2026
5a62253
docs(examples): Add multiple marketplace registrations example
openhands-agent Mar 18, 2026
e80a7f5
refactor(examples): Simplify multiple marketplace example
openhands-agent Mar 18, 2026
8fe1850
refactor(examples): Use Agent + Conversation pattern for marketplace …
openhands-agent Mar 18, 2026
ddcc90a
feat(sdk): Add conversation.load_plugin() for on-demand plugin loading
openhands-agent Mar 18, 2026
5822988
fix(examples): Make marketplace example a proper runnable demo
openhands-agent Mar 18, 2026
33a45a0
fix: Fix CI failures - lint, type errors, and version bump to 1.15.0
openhands-agent Mar 18, 2026
8ac5072
fix: Revert version bump - version changes are handled by release wor…
openhands-agent Mar 19, 2026
976a9ab
refactor: Simplify marketplace example with pre-created files
openhands-agent Mar 19, 2026
1f6d823
feat: Add auto-load marketplace to example
openhands-agent Mar 19, 2026
bb94316
docs: Reference Claude Code plugin syntax in example
openhands-agent Mar 19, 2026
4664c63
fix: address code review feedback for multiple marketplace registrations
openhands-agent Mar 19, 2026
964a9e9
fix: resolve ruff lint and format issues
openhands-agent Mar 19, 2026
4052e87
feat: Wire auto_load='all' into startup and deprecate remaining APIs
openhands-agent Mar 19, 2026
56011ba
fix: Line length violation in local_conversation.py
openhands-agent Mar 20, 2026
abb8b9a
style: Apply ruff formatting fixes
openhands-agent Mar 20, 2026
8c4510b
Merge branch 'main' into feat/multiple-marketplace-registrations
enyst Mar 20, 2026
94ebd83
Merge branch 'main' into feat/multiple-marketplace-registrations
enyst Mar 21, 2026
2bfa3f5
fix: Complete registered_marketplaces as source of truth for skill lo…
openhands-agent Mar 28, 2026
2b77e5a
Merge branch 'main' into feat/multiple-marketplace-registrations
enyst Apr 5, 2026
270226b
fix(sdk): reinitialize tools after runtime plugin load
enyst Apr 5, 2026
4617485
revert: runtime plugin reload reinitialization fix
enyst Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Multiple Marketplace Registrations

Register multiple marketplaces and load plugins on-demand.

## Usage

```bash
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 all plugins at conversation start
),
MarketplaceRegistration(
name="experimental",
source="github:company/experimental",
# auto_load=None - registered but not auto-loaded
),
],
)

# 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

- [43_mixed_marketplace_skills](../../01_standalone_sdk/43_mixed_marketplace_skills/) - Example marketplace used here
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "auto-marketplace",
"owner": {"name": "Demo"},
"plugins": [{"name": "helper", "source": "./plugins/helper"}],
"skills": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "helper",
"version": "1.0.0",
"description": "A helper plugin with utility skills"
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "demo-marketplace",
"owner": {"name": "Demo"},
"plugins": [{"name": "greeter", "source": "./plugins/greeter"}],
"skills": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "greeter",
"version": "1.0.0",
"description": "A greeting plugin"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: greeter-skill
description: Generates friendly greetings
---
# Greeter Skill
When asked to greet someone, respond with a warm, friendly greeting.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Example: Multiple Marketplace Registrations

Demonstrates two loading strategies for marketplace plugins:

- auto_load="all": Plugins loaded automatically at conversation start
- auto_load=None: Plugins loaded on-demand via conversation.load_plugin()

This example uses pre-created marketplaces in:
- ./auto_marketplace/ - auto-loaded at conversation start
- ./demo_marketplace/ - loaded on-demand
"""

import os
from pathlib import Path

from openhands.sdk import LLM, Agent, AgentContext, Conversation
from openhands.sdk.plugin import MarketplaceRegistration


SCRIPT_DIR = Path(__file__).parent


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 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(SCRIPT_DIR / "demo_marketplace"),
# auto_load=None (default) - use load_plugin() to load
),
],
)

agent = Agent(llm=llm, tools=[], agent_context=agent_context)
conversation = Conversation(agent=agent, workspace=os.getcwd())

# 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
if resolved:
print(f"Loaded {len(resolved)} plugin(s):")
for plugin in resolved:
print(f" - {plugin.source}")

# 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}")


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()
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ConversationSortOrder,
GenerateTitleRequest,
GenerateTitleResponse,
LoadPluginRequest,
SendMessageRequest,
SetConfirmationPolicyRequest,
SetSecurityAnalyzerRequest,
Expand Down Expand Up @@ -338,3 +339,21 @@ 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()
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,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 = {}
Expand Down
13 changes: 13 additions & 0 deletions openhands-agent-server/openhands/agent_server/event_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,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")
Expand Down
11 changes: 11 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,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"""

Expand Down
36 changes: 35 additions & 1 deletion openhands-agent-server/openhands/agent_server/skills_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Business logic is delegated to skills_service.py.
"""

import warnings
from typing import Literal

from fastapi import APIRouter
Expand All @@ -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"])
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still only a partial replacement for SkillsRequest.marketplace_path. When registered_marketplaces is provided, the router only extracts repo_path from the first public registration, and load_all_skills() still drives the base public catalog through load_available_skills(... marketplace_path=...). So source / ref from the registration are ignored here, and /skills can still return the default OpenHands/extensions data even when the caller registered a different public marketplace source/branch. I’d make /skills fully registry-driven once registrations are present, or explicitly narrow the API contract until source/ref are honored end-to-end.`

# 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,
Expand All @@ -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
Expand Down
Loading
Loading