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
64 changes: 43 additions & 21 deletions openhands-sdk/openhands/sdk/subagent/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def create_security_expert(llm):
from threading import RLock
from typing import TYPE_CHECKING, NamedTuple

from pydantic import SecretStr

from openhands.sdk.logger import get_logger
from openhands.sdk.subagent.load import (
load_project_agents,
Expand All @@ -44,10 +46,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 All @@ -71,19 +73,34 @@ def register_agent(
Raises:
ValueError: If an agent with the same name already exists
"""
try:
from openhands.sdk.llm.llm import LLM as _LLM

_model_placeholder = "__introspect__"
agent = factory_func(_LLM(model=_model_placeholder, api_key=SecretStr("n/a")))
definition = AgentDefinition.from_agent(
agent, name=name, description=description
)
# If the model was our placeholder, the factory didn't set one explicitly
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.

Why is introspect necessary? Could it happen differently?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I was thinking about that. The point is, it could but I will have to change the signature of register_agent, i.e., adding as an arg the AgentDefinition.

The contro on this choiche are:

  • we change the API
  • the API for an sdk-user to register an agent become very complex

In this way we can reconstruct the AgentDefinition from the factory.

This time I went for giving the user a better experience :-)

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.

@OpenHands understand the question and answer, investigate all you need, including PR description and discussion and PR code. Why exactly do we need two different behaviors, split via introspect, and what are 2-3 alternatives? Note that you should also take a look at LLM Profiles in the SDK codebase

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm on it! enyst can track my progress at all-hands.dev

Copy link
Copy Markdown

@openhands-ai openhands-ai bot Mar 3, 2026

Choose a reason for hiding this comment

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

(HUMAN: I'll just replace here the answers)

Alternatives (2–3):

  1. Make declarative registration explicit: add a register_agent_definition(...) / require passing an AgentDefinition for remote-forwardable agents (no introspection).
  2. Split local-only vs remote-forwardable APIs: keep register_agent() local, add a separate remote-safe registration function that only accepts declarative definitions.
  3. Extend AgentDefinition to support LLM Profiles / full LLM config: e.g., llm_profile or embedded LLM config—more faithful than introspection, but introduces distribution/security concerns.

LLM Profiles note

I confirmed the SDK’s LLMProfileStore persists and loads full LLM configurations, but AgentDefinition currently only carries a model string (“inherit” vs override) and does not represent “use profile X”. That makes introspection inherently limited for profile-driven factories, and helps explain why a fallback path is necessary.

HUMAN:

isn't the model actually a profile name?

No—in the subagent system, AgentDefinition.model is treated as an LLM model identifier, not an LLM profile name.

You can see that in two places:

  • In the schema itself (openhands-sdk/openhands/sdk/subagent/schema.py):

    model: str = Field(default="inherit", description="Model to use ('inherit' uses parent model)")
  • And, more importantly, in how it’s applied when building a subagent (agent_definition_to_factory in subagent/registry.py):

    if agent_def.model and agent_def.model != "inherit":
        llm = llm.model_copy(update={"model": agent_def.model})

That code only changes LLM.model (a string passed through to LiteLLM). It does not load anything from disk or resolve a “profile”.

Where profiles are used in the SDK

LLM Profiles are a separate mechanism:

  • Profiles are persisted by LLMProfileStore (openhands.sdk.llm.llm_profile_store.LLMProfileStore)

  • They’re referenced by name in FallbackStrategy.fallback_llms, and loaded like:

    fb = self._profile_store.load(name)  # name is a profile name like "gpt4.json"

So a “profile name” is something like "gpt4" / "gpt4.json" passed to LLMProfileStore.load(...), not something that LLM.model_copy(update={"model": ...}) would understand.

Implication for the introspection logic

If a user factory internally loads a profile (e.g., LLMProfileStore().load("foo")), introspection may extract the resulting .model string, but remote instantiation still won’t reproduce the full profile config (base_url, provider-specific auth, etc.) because the forwarded AgentDefinition only carries model, not “profile”.

If we wanted model: in agent markdown / AgentDefinition to mean “profile”, we’d need a deliberate design change (e.g., add llm_profile field, or support a profile:xyz syntax and resolve it on the server).

Copy link
Copy Markdown
Collaborator

@enyst enyst Mar 4, 2026

Choose a reason for hiding this comment

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

I’m not sure, this one makes sense to me 🤔

Extend AgentDefinition to support LLM Profiles / full LLM config: e.g., llm_profile or embedded LLM config

Remote has to send full LLM profile today for main agent. We could

  • send full profile for sub-agents too
  • send profile references for sub-agents / agents

Seems the same touched in this discussion? #2186 (comment)

The latter implies a mechanism to send/retrieve profiles, which we need anyway outside sub-agents, simply for profile management from a remote client I think, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This approach carries the problem I mentioned above and because of that I want for introspection.

Precisely: Dropping introspection means register_agent(name, factory_func, description) would produce definitions without tools and system_prompt, which we need on the remote side to build subagents.

If we really want to go for this direction (i.e. giving the LLM profile to the AgentDefinition) there are two options:

Option A — extend the signature with optional fields:

def register_agent(
    name, factory_func, description,
    tools: list[str] | None = None,
    system_prompt: str | None = None,
)

backwards-compatible. Users already know these values since they wrote the factory.

Option B — accept an optional AgentDefinition:

def register_agent(
    name, factory_func, description,
    definition: AgentDefinition | None = None,
)

More future-proof (new fields automatically supported), but heavier UX.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@enyst I was looking at other features for file-based agents. And I think having an introspect method from the factory is actually a very cool solution for the user.

The function register_agent stays super simple and user friendly and we take care of the rest (anyway I don't think we will add a lot of features after the one discussed in the issue #2186 )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What about

Option C

def register_agent(
    name,
    factory_func,
    definition: AgentDefinition | str,
)

And then we construct the AgentDefinition from the string if necessary. No breaking changes but support for AgentDefinition.

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.

I deeply apologize but today I don't have much bandwidth. I just want to note quickly, IMHO introspection is simply a smell, it smells like maybe we can dig into to see if something's not quite right. I'm not at war with it 🙏 😅

I won't lie, I am kinda at war with this: "the forwarded AgentDefinition carries model, not “profile”. --- I don't see much good from carrying model, except in special cases when we want it to, e.g., have the same profile id with model name, to satisfy Anthropic.

I don't really understand why, after the other PR too, we couldn't just say ".model" is a profile id; we add a small API to CRUD profiles for remote-local sync; maybe in the future deprecate full LLM config sending over in any other way except profiles API - but maybe I'm dense sorry, I might miss details

if definition.model == _model_placeholder:
definition = definition.model_copy(update={"model": "inherit"})
except Exception:
logger.debug(f"Could not introspect factory for agent '{name}'")
definition = AgentDefinition(name=name, description=description)

with _registry_lock:
if name in _agent_factories:
raise ValueError(f"Agent '{name}' already registered")

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


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 +110,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 +216,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 +248,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 +310,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
40 changes: 39 additions & 1 deletion openhands-sdk/openhands/sdk/subagent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

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

import frontmatter
from pydantic import BaseModel, Field


if TYPE_CHECKING:
from openhands.sdk.agent.base import AgentBase


def _extract_examples(description: str) -> list[str]:
"""Extract <example> tags from description for agent triggering."""
pattern = r"<example>(.*?)</example>"
Expand Down Expand Up @@ -111,3 +115,37 @@ def load(cls, agent_path: Path) -> AgentDefinition:
when_to_use_examples=when_to_use_examples,
metadata=metadata,
)

@classmethod
def from_agent(
cls,
agent: AgentBase,
name: str,
description: str,
) -> AgentDefinition:
"""Build an AgentDefinition by introspecting a live Agent instance.

Args:
agent: The agent to extract configuration from.
name: Name for the agent definition.
description: Human-readable description of the agent.

Returns:
A fully populated AgentDefinition.
"""
tools = [t.name for t in agent.tools]

if agent.agent_context and agent.agent_context.system_message_suffix:
system_prompt = agent.agent_context.system_message_suffix
else:
system_prompt = ""

model = agent.llm.model if agent.llm.model else "inherit"

return cls(
name=name,
description=description,
tools=tools,
system_prompt=system_prompt,
model=model,
)
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
Loading
Loading