Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
)
from openhands.sdk import LLM, Agent, TextContent
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.profiles.resolver import DanglingMcpServerRef, ProfileNotFound
from openhands.sdk.tool.client_tool import ClientToolRegistrationError
from openhands.sdk.workspace import LocalWorkspace
from openhands.tools.preset.default import get_default_tools
Expand Down Expand Up @@ -197,6 +198,13 @@ async def start_conversation(
"""Start a conversation in the local environment."""
try:
info, is_new = await conversation_service.start_conversation(request)
except ProfileNotFound as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except DanglingMcpServerRef as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message": str(e), "dangling_mcp_server_refs": e.missing},
) from e
except ClientToolRegistrationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
Expand Down
106 changes: 103 additions & 3 deletions openhands-agent-server/openhands/agent_server/conversation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ConversationInfo,
ConversationPage,
ConversationSortOrder,
LaunchedProfile,
StartConversationRequest,
StoredConversation,
UpdateConversationRequest,
Expand Down Expand Up @@ -240,6 +241,59 @@ def _prepare_request_workspace(
logger = logging.getLogger(__name__)


def _resolve_agent_from_profile(
profile_id: "UUID",
cipher: "Cipher | None",
mcp_config: "Any",
) -> "tuple[AgentBase, LaunchedProfile]":
"""Load and resolve an agent profile by id, returning the built agent + provenance.

Runs synchronously (call via ``asyncio.to_thread`` from async context).

Args:
mcp_config: Global MCP config already loaded by the caller using the
server's cipher. Passed explicitly so this free function never
touches the settings-store singleton (which may not have been
initialised with the correct cipher yet).

Raises:
ProfileNotFound: No stored profile has ``profile_id``.
DanglingMcpServerRef: A referenced MCP server is absent from the global config.
ValueError: Profile load or settings validation failure.
"""
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
from openhands.sdk.profiles.agent_profile_store import AgentProfileStore
from openhands.sdk.profiles.resolver import ProfileNotFound, resolve_agent_profile

store = AgentProfileStore()
profile_name = store.name_for_id(profile_id)
if profile_name is None:
raise ProfileNotFound(f"Agent profile with id '{profile_id}' not found")

try:
profile = store.load(profile_name, cipher=cipher)
except FileNotFoundError:
raise ProfileNotFound(
f"Agent profile '{profile_name}' (id={profile_id}) not found"
)
except ValueError as exc:
raise ValueError(
f"Failed to load agent profile '{profile_name}': {exc}"
) from exc

llm_store = LLMProfileStore()
try:
settings_config = resolve_agent_profile(
profile, llm_store=llm_store, mcp_config=mcp_config, cipher=cipher
)
except (TypeError, ValueError) as exc:
raise ValueError(f"Profile '{profile_name}' failed to resolve: {exc}") from exc

agent = settings_config.create_agent()
launched = LaunchedProfile(profile_id=profile.id, revision=profile.revision)
return agent, launched


def _compose_conversation_info(
stored: StoredConversation, state: ConversationState
) -> ConversationInfo:
Expand Down Expand Up @@ -309,6 +363,7 @@ def _compose_conversation_info(
available_models=available_models,
supports_runtime_model_switch=supports_runtime_model_switch,
client_tools=stored.client_tools,
launched_profile=stored.launched_profile,
)


Expand Down Expand Up @@ -612,6 +667,28 @@ async def _start_conversation(
)
return conversation_info, False

# Profile resolution must happen before _prepare_request_workspace (which
# asserts request.agent is not None) and before model_dump so the resolved
# agent is captured in request_data.
launched_profile: LaunchedProfile | None = None
if request.agent_profile_id is not None:
# get_settings_store() is safe here: get_instance() initialises the
# singleton with the server cipher before any conversation can start.
from openhands.agent_server.persistence import (
PersistedSettings,
get_settings_store,
)

settings = get_settings_store().load() or PersistedSettings()
mcp_config = settings.agent_settings.mcp_config
resolved_agent, launched_profile = await asyncio.to_thread(
_resolve_agent_from_profile,
Comment thread
enyst marked this conversation as resolved.
request.agent_profile_id,
self.cipher,
mcp_config,
)
request = request.model_copy(update={"agent": resolved_agent})

request = _prepare_request_workspace(request, conversation_id)

# Dynamically register tools from client's registry
Expand Down Expand Up @@ -674,7 +751,13 @@ async def _start_conversation(
# serialize to plain strings. Pass expose_secrets=True so StaticSecret values
# are preserved through the round-trip; the dict is only used in-process to
# construct StoredConversation, not sent over the network.
request_data = request.model_dump(mode="json", context={"expose_secrets": True})
# agent_profile_id is excluded: it was resolved into `launched_profile`
# above and must not re-trigger the mutual-exclusivity validator.
request_data = request.model_dump(
mode="json",
context={"expose_secrets": True},
exclude={"agent_profile_id"},
)

# If secrets_encrypted=True, the agent's secrets (e.g., LLM api_key) are
# cipher-encrypted and need decryption during model validation. Pass the
Expand All @@ -686,11 +769,23 @@ async def _start_conversation(
"Set OH_SECRET_KEY environment variable."
)
stored = StoredConversation.model_validate(
{"id": conversation_id, **request_data},
{
"id": conversation_id,
**request_data,
"launched_profile": (
launched_profile.model_dump(mode="json")
if launched_profile is not None
else None
),
},
context={"cipher": self.cipher},
)
else:
stored = StoredConversation(id=conversation_id, **request_data)
stored = StoredConversation(
id=conversation_id,
launched_profile=launched_profile,
**request_data,
)
event_service = await self._start_event_service(stored)
initial_message = request.initial_message
if initial_message:
Expand Down Expand Up @@ -1099,6 +1194,11 @@ async def __aexit__(self, exc_type, exc_value, traceback):

@classmethod
def get_instance(cls, config: Config) -> "ConversationService":
# Initialise the settings-store singleton with the server cipher before
# any conversation handler can call get_settings_store() without config.
from openhands.agent_server.persistence import get_settings_store

get_settings_store(config)
return ConversationService(
conversations_dir=config.conversations_path,
webhook_specs=config.webhooks,
Expand Down
24 changes: 24 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
TextContent as TextContent,
)
from openhands.sdk.llm.utils.metrics import MetricsSnapshot
from openhands.sdk.profiles.agent_profile import LaunchedProfile as LaunchedProfile
from openhands.sdk.secret import SecretSource
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.confirmation_policy import (
Expand Down Expand Up @@ -78,13 +79,26 @@ class StoredConversation(StartConversationRequest):
Extends StartConversationRequest with server-assigned fields.
"""

# agent_profile_id is resolved into launched_profile at creation; exclude from
# the persistence payload so it does not re-appear in meta.json.
agent_profile_id: UUID | None = Field(default=None, exclude=True)

id: OpenHandsUUID
title: str | None = Field(
default=None, description="User-defined title for the conversation"
)
metrics: MetricsSnapshot | None = None
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
launched_profile: LaunchedProfile | None = Field(
Comment thread
enyst marked this conversation as resolved.
default=None,
description=(
"Provenance snapshot of the agent profile that launched this "
"conversation. Set at creation when `agent_profile_id` is supplied; "
"``None`` for conversations started directly with `agent` or "
"`agent_settings`."
),
)


class _ConversationInfoBase(BaseModel):
Expand Down Expand Up @@ -234,6 +248,16 @@ class _ConversationInfoBase(BaseModel):
"and before the conversation has started a session."
),
)
launched_profile: LaunchedProfile | None = Field(
default=None,
description=(
"Provenance snapshot of the agent profile that launched this "
"conversation. Set at creation when the conversation was started via "
"``agent_profile_id``; ``None`` for conversations started directly "
"with ``agent`` or ``agent_settings``. Clients use this to identify "
"which profile is current without fragile settings-comparison."
),
)


class ConversationInfo(_ConversationInfoBase):
Expand Down
67 changes: 51 additions & 16 deletions openhands-sdk/openhands/sdk/conversation/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
from typing import Annotated, Any, Literal, cast
from uuid import UUID

from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from pydantic import (
BaseModel,
Discriminator,
Field,
Tag,
field_serializer,
model_validator,
)

from openhands.sdk.agent.acp_agent import ACPAgent as ACPAgent
from openhands.sdk.agent.agent import Agent as Agent
Expand Down Expand Up @@ -234,6 +241,16 @@ class StartConversationRequest(BaseModel):
"used to construct the concrete agent."
),
)
agent_profile_id: UUID | None = Field(
default=None,
Comment thread
simonrosenberg marked this conversation as resolved.
description=(
"Optional agent profile ID. When set, the agent-server resolves the "
"referenced profile server-side (stores + cipher are required) and "
"builds the agent from it. Mutually exclusive with `agent` and "
"`agent_settings`. The SDK validator enforces exclusivity only — "
"resolution happens in conversation_service, not here."
),
)
agent: AgentBase = Field(default=cast(AgentBase, None))

@model_validator(mode="before")
Expand All @@ -242,28 +259,46 @@ def _populate_agent_from_settings(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
payload = dict(data)
if payload.get("agent") is None and payload.get("agent_settings") is not None:
from openhands.sdk.settings.model import validate_agent_settings
has_profile_id = payload.get("agent_profile_id") is not None
has_agent = payload.get("agent") is not None
has_agent_settings = payload.get("agent_settings") is not None
if has_profile_id and (has_agent or has_agent_settings):
raise ValueError(
"`agent_profile_id` is mutually exclusive with"
" `agent` and `agent_settings`"
)
if not has_profile_id:
if payload.get("agent") is None and has_agent_settings:
from openhands.sdk.settings.model import validate_agent_settings

try:
payload["agent"] = validate_agent_settings(
payload["agent_settings"]
).create_agent()
except (TypeError, ValueError) as exc:
raise ValueError(str(exc)) from exc
elif isinstance(payload.get("agent"), dict):
agent_payload = dict(payload["agent"])
if "kind" not in agent_payload and "llm" in agent_payload:
agent_payload["kind"] = "Agent"
payload["agent"] = agent_payload
try:
payload["agent"] = validate_agent_settings(
payload["agent_settings"]
).create_agent()
except (TypeError, ValueError) as exc:
raise ValueError(str(exc)) from exc
elif isinstance(payload.get("agent"), dict):
agent_payload = dict(payload["agent"])
if "kind" not in agent_payload and "llm" in agent_payload:
agent_payload["kind"] = "Agent"
payload["agent"] = agent_payload
return payload

@model_validator(mode="after")
def _require_agent(self) -> StartConversationRequest:
if self.agent is None:
raise ValueError("Either `agent` or `agent_settings` must be provided")
if self.agent is None and self.agent_profile_id is None:
Comment thread
simonrosenberg marked this conversation as resolved.
raise ValueError(
"One of `agent`, `agent_settings`, or"
" `agent_profile_id` must be provided"
)
return self

@field_serializer("agent", mode="wrap")
def _serialize_agent(self, value: AgentBase | None, handler: Any) -> Any:
if value is None:
return None
return handler(value)


class StartACPConversationRequest(StartConversationRequest):
"""Deprecated compatibility alias for ACP-capable start requests.
Expand Down
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
ACPAgentProfile,
AgentProfile,
AgentProfileBase,
LaunchedProfile,
OpenHandsAgentProfile,
ProfileVerificationSettings,
validate_agent_profile,
Expand Down Expand Up @@ -37,6 +38,7 @@
"AgentProfileDiagnostics",
"AgentProfileStore",
"DanglingMcpServerRef",
"LaunchedProfile",
"OpenHandsAgentProfile",
"ProfileLimitExceeded",
"ProfileNotFound",
Expand Down
17 changes: 17 additions & 0 deletions openhands-sdk/openhands/sdk/profiles/agent_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,23 @@ class ACPAgentProfile(AgentProfileBase):
)


class LaunchedProfile(BaseModel):
"""Provenance snapshot recorded when a profile launches a conversation.

Stored on ``StoredConversation`` and projected onto ``ConversationInfo`` so
ts-client ``deriveSwitchPlan`` can identify which profile is current without
Comment thread
enyst marked this conversation as resolved.
fragile settings-comparison. See #3720.
"""

profile_id: UUID = Field(
description="Stable id of the profile that launched the conversation."
)
revision: int = Field(
ge=0,
description="Revision of the profile at launch time.",
)


def _agent_profile_discriminator(value: Any) -> str:
"""Discriminator for :data:`AgentProfile` — defaults to ``'openhands'``.

Expand Down
15 changes: 15 additions & 0 deletions openhands-sdk/openhands/sdk/profiles/agent_profile_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@


if TYPE_CHECKING:
from uuid import UUID

from openhands.sdk.profiles.agent_profile import (
ACPAgentProfile,
OpenHandsAgentProfile,
Expand Down Expand Up @@ -305,3 +307,16 @@ def list_summaries(self) -> list[dict[str, Any]]:
}
)
return summaries

def name_for_id(self, profile_id: str | UUID) -> str | None:
"""Return the stored name for a stable profile id, or ``None`` if not found.

Scans ``list_summaries()`` under the lock so the lookup is consistent
with the on-disk state at the time of the call. Mirrors the id→name
resolution that used to be open-coded by each caller.
"""
target = str(profile_id)
for summary in self.list_summaries():
if str(summary.get("id")) == target:
return str(summary["name"])
return None
Loading
Loading