Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
)
from openhands.sdk.event import MessageEvent
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.sdk.subagent.registry import (
agent_definition_to_factory,
register_agent_if_absent,
)
from openhands.sdk.utils.cipher import Cipher


Expand Down Expand Up @@ -240,6 +244,20 @@ async def start_conversation(
f"{list(request.tool_module_qualnames.keys())}"
)

# Register subagent definitions from the client's registry
if request.subagent_definitions:
for agent_def in request.subagent_definitions:
factory = agent_definition_to_factory(agent_def)
if not agent_def.description:
agent_def = agent_def.model_copy(
update={"description": f"Remote agent: {agent_def.name}"}
)
register_agent_if_absent(factory, agent_def)
logger.info(
f"Registered {len(request.subagent_definitions)} subagent "
f"definitions for conversation {conversation_id}: "
f"{[d.name for d in request.subagent_definitions]}"
)
# Plugin loading is now handled lazily by LocalConversation.
# Just pass the plugin specs through to StoredConversation.
# LocalConversation will:
Expand Down
8 changes: 8 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ConfirmationPolicyBase,
NeverConfirm,
)
from openhands.sdk.subagent.schema import AgentDefinition
from openhands.sdk.utils.models import DiscriminatedUnionMixin, OpenHandsModel
from openhands.sdk.workspace import LocalWorkspace

Expand Down Expand Up @@ -108,6 +109,13 @@ class StartConversationRequest(BaseModel):
"to register the tools for this conversation."
),
)
subagent_definitions: list[AgentDefinition] = Field(
default_factory=list,
description=(
"Subagent definitions from the client's registry. These are "
"registered on the server so DelegateTool can advertise them."
),
)
plugins: list[PluginSource] | None = Field(
default=None,
description=(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,10 +635,14 @@ def __init__(

if should_create:
# Import here to avoid circular imports
from openhands.sdk.subagent.registry import (
get_registered_agent_definitions,
)
from openhands.sdk.tool.registry import get_tool_module_qualnames

tool_qualnames = get_tool_module_qualnames()
logger.debug(f"Sending tool_module_qualnames to server: {tool_qualnames}")
subagent_defs = get_registered_agent_definitions()
payload = {
"agent": agent.model_dump(
mode="json", context={"expose_secrets": True}
Expand All @@ -652,6 +656,8 @@ def __init__(
).model_dump(),
# Include tool module qualnames for dynamic registration on server
"tool_module_qualnames": tool_qualnames,
# Include subagent definitions for delegate tool on server
"subagent_definitions": [d.model_dump() for d in subagent_defs],
# Include plugins to load on server
"plugins": [p.model_dump() for p in plugins] if plugins else None,
}
Expand Down
47 changes: 26 additions & 21 deletions openhands-sdk/openhands/sdk/subagent/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ def create_security_expert(llm):


class AgentFactory(NamedTuple):
"""Simple container for an agent factory function and its description."""
"""Simple container for an agent factory function and its definition."""

factory_func: Callable[["LLM"], "Agent"]
description: str
definition: AgentDefinition


# Global registry for user-registered agent factories
Expand Down Expand Up @@ -76,14 +76,14 @@ def register_agent(
raise ValueError(f"Agent '{name}' already registered")

_agent_factories[name] = AgentFactory(
factory_func=factory_func, description=description
factory_func=factory_func,
definition=AgentDefinition(name=name, description=description),
)


def register_agent_if_absent(
name: str,
factory_func: Callable[["LLM"], "Agent"],
description: str,
definition: AgentDefinition,
) -> bool:
"""
Register a custom agent if no agent with that name exists yet.
Expand All @@ -93,20 +93,19 @@ def register_agent_if_absent(
with programmatically registered agents.

Args:
name: Unique name for the agent
factory_func: Function that takes an LLM and returns an Agent
description: Human-readable description of what this agent does
definition: AgentDefinition describing the agent.

Returns:
True if the agent was registered, False if an agent with that name
already existed.
"""
with _registry_lock:
if name in _agent_factories:
if definition.name in _agent_factories:
return False

_agent_factories[name] = AgentFactory(
factory_func=factory_func, description=description
_agent_factories[definition.name] = AgentFactory(
factory_func=factory_func, definition=definition
)
return True

Expand Down Expand Up @@ -200,11 +199,11 @@ def register_file_agents(work_dir: str | Path) -> list[str]:
registered: list[str] = []
for agent_def in deduplicated:
factory = agent_definition_to_factory(agent_def)
was_registered = register_agent_if_absent(
name=agent_def.name,
factory_func=factory,
description=agent_def.description or f"File-based agent: {agent_def.name}",
)
if not agent_def.description:
agent_def = agent_def.model_copy(
update={"description": f"File-based agent: {agent_def.name}"}
)
was_registered = register_agent_if_absent(factory, agent_def)
if was_registered:
registered.append(agent_def.name)
logger.info(
Expand Down Expand Up @@ -232,11 +231,11 @@ def register_plugin_agents(agents: list[AgentDefinition]) -> list[str]:
registered: list[str] = []
for agent_def in agents:
factory = agent_definition_to_factory(agent_def)
was_registered = register_agent_if_absent(
name=agent_def.name,
factory_func=factory,
description=agent_def.description or f"Plugin agent: {agent_def.name}",
)
if not agent_def.description:
agent_def = agent_def.model_copy(
update={"description": f"Plugin agent: {agent_def.name}"}
)
was_registered = register_agent_if_absent(factory, agent_def)
if was_registered:
registered.append(agent_def.name)
logger.info(f"Registered plugin agent '{agent_def.name}'")
Expand Down Expand Up @@ -294,11 +293,17 @@ def get_factory_info() -> str:
return "\n".join(info_lines)

for name, factory in sorted(user_factories.items()):
info_lines.append(f"- **{name}**: {factory.description}")
info_lines.append(f"- **{name}**: {factory.definition.description}")

return "\n".join(info_lines)


def get_registered_agent_definitions() -> list[AgentDefinition]:
"""Return stored AgentDefinitions for forwarding to remote servers."""
with _registry_lock:
return [f.definition for f in _agent_factories.values()]


def _reset_registry_for_tests() -> None:
"""Clear the registry for tests to avoid cross-test contamination."""
with _registry_lock:
Expand Down
10 changes: 5 additions & 5 deletions openhands-tools/openhands/tools/preset/default.py
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.

Can we add a new test to tests/cross/test_remote_conversation_live_server.py that spawns up a REAL agent server with the real sub-agent requests, and make sure it works?

Copy link
Copy Markdown
Contributor Author

@VascoSch92 VascoSch92 Mar 3, 2026

Choose a reason for hiding this comment

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

@xingyaoww

I found that register_agent() was producing hollow AgentDefinition stubs (just name + description, no tools / system_prompt). These would leak to the remote server via get_registered_agent_definitions() and get recreated as useless agents (no tools or/and attached system prompt).

I didn't see that before because the machinery to load subagents is using register_agent_if_absent() which needs an AgentDefinition (so from this side everything is good).

I solved this by adding a AgentDefinition.from_agent() classmethod that introspects a live Agent instance and extracts tools, system_prompt, and model into a complete definition. register_agent() now calls the factory with a placeholder LLM and delegates to AgentDefinition.from_agent().

Having this as a classmethod on AgentDefinition is much better from a maintainability perspective — the logic for building a definition from an agent lives right next to the schema it populates (alongside
load() for file-based definitions), making it easier to keep in sync when fields change.

Why this solution? This keeps register_agent(name, factory_func, description) user-friendly — no signature change needed — while producing complete definitions that survive the HTTP roundtrip to the
server.

Everything is tested, and also cross tested.

Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,11 @@ def register_builtins_agents(cli_mode: bool = False) -> list[str]:
registered: list[str] = []
for agent_def in builtins_agents_def:
factory = agent_definition_to_factory(agent_def)
was_registered = register_agent_if_absent(
name=agent_def.name,
factory_func=factory,
description=agent_def.description or f"Agent: {agent_def.name}",
)
if not agent_def.description:
agent_def = agent_def.model_copy(
update={"description": f"Agent: {agent_def.name}"}
)
was_registered = register_agent_if_absent(factory, agent_def)
if was_registered:
registered.append(agent_def.name)
logger.info(
Expand Down
115 changes: 115 additions & 0 deletions tests/agent_server/test_subagent_registration_on_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for subagent registration on remote server.

When a client sends a StartConversationRequest to the agent-server,
subagent definitions registered on the client must be forwarded to
the server so that delegation tools can use them in their description.
"""

import tempfile
from pathlib import Path
from unittest.mock import AsyncMock

import pytest
from pydantic import SecretStr

from openhands.agent_server.conversation_service import ConversationService
from openhands.agent_server.models import StartConversationRequest
from openhands.sdk import LLM, Agent
from openhands.sdk.subagent.registry import (
_reset_registry_for_tests,
get_factory_info,
get_registered_agent_definitions,
register_agent_if_absent,
)
from openhands.sdk.subagent.schema import AgentDefinition
from openhands.sdk.workspace import LocalWorkspace


@pytest.fixture(autouse=True)
def _clean_subagent_registry():
_reset_registry_for_tests()
yield
_reset_registry_for_tests()


@pytest.fixture
def conversation_service():
with tempfile.TemporaryDirectory() as temp_dir:
service = ConversationService(
conversations_dir=Path(temp_dir) / "conversations",
)
service._event_services = {}
yield service


def _make_request(**overrides) -> StartConversationRequest:
agent = overrides.pop(
"agent",
Agent(
llm=LLM(
model="openai/gpt-4o",
api_key=SecretStr("test-key"),
base_url="https://api.openai.com/v1",
),
tools=[],
),
)
workspace = overrides.pop("workspace", LocalWorkspace(working_dir="/workspace"))
return StartConversationRequest(
agent=agent,
workspace=workspace,
**overrides,
)


@pytest.mark.asyncio
async def test_start_conversation_registers_subagent_definitions(conversation_service):
"""Subagent definitions in the request are registered so the agent can see them."""
agent_defs = [
AgentDefinition(
name="bash",
description="Command execution specialist",
tools=["terminal"],
system_prompt="You are a bash specialist.",
),
AgentDefinition(
name="explore",
description="Codebase exploration agent",
tools=["terminal", "file_editor"],
system_prompt="You are an exploration specialist.",
),
]

request = _make_request(
subagent_definitions=[d.model_dump() for d in agent_defs],
)

mock_event_service = AsyncMock()
conversation_service._start_event_service = AsyncMock(
return_value=mock_event_service
)
mock_event_service.get_state = AsyncMock(return_value=None)

with pytest.raises(Exception):
# Fails later in _compose_conversation_info (None state), but
# subagent registration happens before that point.
await conversation_service.start_conversation(request)

info = get_factory_info()
assert "bash" in info
assert "explore" in info


def test_get_registered_agent_definitions_returns_stored_definitions():
"""Registered definitions are retrievable for forwarding to remote servers."""
for name, desc in [
("bash", "Command execution"),
("explore", "Codebase exploration"),
]:
register_agent_if_absent(
lambda llm: None, # type: ignore[return-value]
AgentDefinition(name=name, description=desc, tools=["terminal"]),
)

defs = get_registered_agent_definitions()
assert {d.name for d in defs} == {"bash", "explore"}
Loading
Loading