Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions examples/01_standalone_sdk/03_activate_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AgentContext,
Conversation,
Event,
ExtensionConfig,
LLMConvertibleEvent,
get_logger,
)
Expand Down Expand Up @@ -87,9 +88,6 @@
system_message_suffix="Always finish your response with the word 'yay!'",
# user_message_suffix is appended to each user message
user_message_suffix="The first character of your response should be 'I'",
# You can also enable automatic load skills from
# public registry at https://github.com/OpenHands/extensions
load_public_skills=True,
)

# Agent
Expand All @@ -103,8 +101,13 @@ def conversation_callback(event: Event):
llm_messages.append(event.to_llm_message())


# ExtensionConfig controls loading of extensions (skills, plugins, hooks)
# from well-known locations at the conversation level.
conversation = Conversation(
agent=agent, callbacks=[conversation_callback], workspace=cwd
agent=agent,
callbacks=[conversation_callback],
workspace=cwd,
extension_config=ExtensionConfig(load_public_extensions=True),
)

print("=" * 100)
Expand Down
2 changes: 0 additions & 2 deletions examples/05_skills_and_plugins/01_loading_agentskills/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@
# Create agent context with loaded skills
agent_context = AgentContext(
skills=list(agent_skills.values()),
# Disable public skills for this demo to keep output focused
load_public_skills=False,
)

# Create agent with tools so it can read skill resources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,8 @@ async def start(self):
self.stored.agent.model_dump(context={"expose_secrets": True}),
)

# Create LocalConversation with plugins and hook_config.
# Plugins are loaded lazily on first run()/send_message() call.
# Hook execution semantics: OpenHands runs hooks sequentially with early-exit
# on block (PreToolUse), unlike Claude Code's parallel execution model.
# Extensions (plugins, hooks, skills) are loaded lazily on first
# run()/send_message() call via ExtensionConfig.resolve().

# Create and store callback wrapper to allow flushing pending events
self._callback_wrapper = AsyncCallbackWrapper(
Expand All @@ -485,6 +483,7 @@ async def start(self):
conversation = LocalConversation(
agent=agent,
workspace=workspace,
extension_config=self.stored.extension_config,
plugins=self.stored.plugins,
persistence_dir=str(self.conversations_dir),
conversation_id=self.stored.id,
Expand Down
42 changes: 41 additions & 1 deletion openhands-sdk/openhands/sdk/context/agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
to_prompt,
)
from openhands.sdk.skills.skill import DEFAULT_MARKETPLACE_PATH
from openhands.sdk.utils.deprecation import warn_deprecated


logger = get_logger(__name__)
Expand Down Expand Up @@ -59,13 +60,21 @@ class AgentContext(BaseModel):
)
load_user_skills: bool = Field(
default=False,
deprecated=(
"Deprecated since v1.18.0; will be removed in v1.23.0. "
"Use ExtensionConfig(load_user_extensions=True) on Conversation."
),
description=(
"Whether to automatically load user skills from ~/.openhands/skills/ "
"and ~/.openhands/microagents/ (for backward compatibility). "
),
)
load_public_skills: bool = Field(
default=False,
deprecated=(
"Deprecated since v1.18.0; will be removed in v1.23.0. "
"Use ExtensionConfig(load_public_extensions=True) on Conversation."
),
description=(
"Whether to automatically load skills from the public OpenHands "
"skills repository at https://github.com/OpenHands/extensions. "
Expand All @@ -74,6 +83,10 @@ class AgentContext(BaseModel):
)
marketplace_path: str | None = Field(
default=DEFAULT_MARKETPLACE_PATH,
deprecated=(
"Deprecated since v1.18.0; will be removed in v1.23.0. "
"Use ExtensionConfig(marketplace_path=...) on Conversation."
),
description=(
"Relative marketplace JSON path within the public skills repository. "
"Set to None to load all public skills without marketplace filtering."
Expand Down Expand Up @@ -114,10 +127,37 @@ 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.

.. deprecated:: 1.18.0
Use ``ExtensionConfig(load_user_extensions=...,
load_public_extensions=...)`` on ``Conversation`` instead.
Will be removed in v1.23.0.
"""
if not self.load_user_skills and not self.load_public_skills:
return self

_details = (
"Use ExtensionConfig(load_user_extensions=..., "
"load_public_extensions=...) on Conversation instead."
)
if self.load_user_skills:
warn_deprecated(
"AgentContext.load_user_skills",
deprecated_in="1.18.0",
removed_in="1.23.0",
details=_details,
stacklevel=3,
)
if self.load_public_skills:
warn_deprecated(
"AgentContext.load_public_skills",
deprecated_in="1.18.0",
removed_in="1.23.0",
details=_details,
stacklevel=3,
)

auto_skills = load_available_skills(
work_dir=None,
include_user=self.load_user_skills,
Expand Down
14 changes: 12 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from openhands.sdk.agent.acp_agent import ACPAgent
from openhands.sdk.agent.agent import Agent
from openhands.sdk.conversation.types import ConversationTags
from openhands.sdk.extensions.config import ExtensionConfig
from openhands.sdk.hooks import HookConfig
from openhands.sdk.llm.message import ImageContent, Message, TextContent
from openhands.sdk.plugin import PluginSource
Expand Down Expand Up @@ -117,12 +118,21 @@ class _StartConversationRequestBase(BaseModel):
"can see user-registered subagents."
),
)
extension_config: ExtensionConfig | None = Field(
default=None,
description=(
"Declarative specification of all extensions to load (skills, "
"plugins, hooks). When provided, ``plugins`` and ``hook_config`` "
"are ignored — use the config object instead."
),
)
plugins: list[PluginSource] | None = Field(
default=None,
description=(
"List of plugins to load for this conversation. Plugins are loaded "
"and their skills/MCP config are merged into the agent. "
"Hooks are extracted and stored for runtime execution."
"Hooks are extracted and stored for runtime execution. "
"Ignored when ``extension_config`` is provided."
),
)
hook_config: HookConfig | None = Field(
Expand All @@ -132,7 +142,7 @@ class _StartConversationRequestBase(BaseModel):
"scripts that run at key lifecycle events (PreToolUse, PostToolUse, "
"UserPromptSubmit, Stop, etc.). If both hook_config and plugins are "
"provided, they are merged with explicit hooks running before plugin "
"hooks."
"hooks. Ignored when ``extension_config`` is provided."
),
)
tags: ConversationTags = Field(
Expand Down
121 changes: 119 additions & 2 deletions tests/agent_server/test_conversation_service_plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Tests for plugin handling in ConversationService.
"""Tests for plugin and extension_config handling in ConversationService.

This module tests plugin handling via the `plugins` list parameter
on StartConversationRequest.
and extension_config via the `extension_config` parameter on
StartConversationRequest.

These tests verify that:
1. Plugin specs are passed through to StoredConversation (for lazy loading)
2. Explicit hook_config is preserved (merging happens lazily in LocalConversation)
3. Plugins ARE persisted (unlike the old eager loading model) since
LocalConversation loads them lazily on first run()/send_message()
4. ExtensionConfig is passed through to StoredConversation and
forwarded to LocalConversation for centralized extension loading.
"""

import tempfile
Expand All @@ -30,6 +33,7 @@
ConversationExecutionStatus,
ConversationState,
)
from openhands.sdk.extensions.config import ExtensionConfig
from openhands.sdk.hooks import HookConfig, HookDefinition, HookMatcher, HookType
from openhands.sdk.plugin import PluginSource
from openhands.sdk.workspace import LocalWorkspace
Expand Down Expand Up @@ -468,3 +472,116 @@ async def test_start_conversation_stores_both_hooks_and_plugins_for_lazy_merge(
# Plugins are stored for lazy loading
assert stored.plugins is not None
assert len(stored.plugins) == 1


# Tests for extension_config


def test_start_conversation_request_has_extension_config_field():
"""Verify StartConversationRequest has extension_config field."""
fields = StartConversationRequest.model_fields
assert "extension_config" in fields


@pytest.mark.asyncio
async def test_start_conversation_with_extension_config(conversation_service, tmp_path):
"""Test that extension_config is passed through to StoredConversation."""
plugin_dir = create_test_plugin_dir(
tmp_path,
skills=[{"name": "ext-skill", "content": "From ExtensionConfig"}],
)

hook_config = HookConfig(
pre_tool_use=[
HookMatcher(
matcher="*",
hooks=[HookDefinition(type=HookType.COMMAND, command="echo ext")],
)
]
)

ext_config = ExtensionConfig(
plugins=[PluginSource(source=str(plugin_dir))],
hook_config=hook_config,
load_public_extensions=True,
)

with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(
llm=LLM(model="gpt-4o", usage_id="test-llm"),
tools=[],
),
workspace=LocalWorkspace(working_dir=temp_dir),
extension_config=ext_config,
)

with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock(spec=EventService)
mock_event_service_class.return_value = mock_event_service

mock_state = ConversationState(
id=uuid4(),
agent=request.agent,
workspace=request.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=request.confirmation_policy,
)
mock_event_service.get_state.return_value = mock_state
mock_event_service.stored = StoredConversation(
id=mock_state.id,
**request.model_dump(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)

await conversation_service.start_conversation(request)

stored = mock_event_service_class.call_args.kwargs["stored"]
assert stored.extension_config is not None
assert len(stored.extension_config.plugins) == 1
assert stored.extension_config.hook_config is not None
assert stored.extension_config.load_public_extensions is True


@pytest.mark.asyncio
async def test_start_conversation_without_extension_config(
conversation_service,
):
"""extension_config defaults to None when not provided."""
with tempfile.TemporaryDirectory() as temp_dir:
request = StartConversationRequest(
agent=Agent(
llm=LLM(model="gpt-4o", usage_id="test-llm"),
tools=[],
),
workspace=LocalWorkspace(working_dir=temp_dir),
)

with patch(
"openhands.agent_server.conversation_service.EventService"
) as mock_event_service_class:
mock_event_service = AsyncMock(spec=EventService)
mock_event_service_class.return_value = mock_event_service

mock_state = ConversationState(
id=uuid4(),
agent=request.agent,
workspace=request.workspace,
execution_status=ConversationExecutionStatus.IDLE,
confirmation_policy=request.confirmation_policy,
)
mock_event_service.get_state.return_value = mock_state
mock_event_service.stored = StoredConversation(
id=mock_state.id,
**request.model_dump(),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)

await conversation_service.start_conversation(request)

stored = mock_event_service_class.call_args.kwargs["stored"]
assert stored.extension_config is None
Loading