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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
ConversationInfo,
ConversationPage,
ConversationSortOrder,
LaunchedProfile,
LaunchedAgentProfile,
StartConversationRequest,
StoredConversation,
UpdateConversationRequest,
Expand Down Expand Up @@ -245,7 +245,7 @@ def _resolve_agent_from_profile(
profile_id: "UUID",
cipher: "Cipher | None",
mcp_config: "Any",
) -> "tuple[AgentBase, LaunchedProfile]":
) -> "tuple[AgentBase, LaunchedAgentProfile]":
"""Load and resolve an agent profile by id, returning the built agent + provenance.

Runs synchronously (call via ``asyncio.to_thread`` from async context).
Expand Down Expand Up @@ -290,7 +290,10 @@ def _resolve_agent_from_profile(
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)
launched = LaunchedAgentProfile(
agent_profile_id=profile.id,
revision=profile.revision,
)
return agent, launched


Expand Down Expand Up @@ -363,7 +366,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,
launched_agent_profile=stored.launched_agent_profile,
)


Expand Down Expand Up @@ -670,7 +673,7 @@ async def _start_conversation(
# 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
launched_agent_profile: LaunchedAgentProfile | 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.
Expand All @@ -681,7 +684,7 @@ async def _start_conversation(

settings = get_settings_store().load() or PersistedSettings()
mcp_config = settings.agent_settings.mcp_config
resolved_agent, launched_profile = await asyncio.to_thread(
resolved_agent, launched_agent_profile = await asyncio.to_thread(
_resolve_agent_from_profile,
request.agent_profile_id,
self.cipher,
Expand Down Expand Up @@ -751,7 +754,7 @@ 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.
# agent_profile_id is excluded: it was resolved into `launched_profile`
# agent_profile_id is excluded: it was resolved into `launched_agent_profile`
# above and must not re-trigger the mutual-exclusivity validator.
request_data = request.model_dump(
mode="json",
Expand All @@ -772,9 +775,9 @@ async def _start_conversation(
{
"id": conversation_id,
**request_data,
"launched_profile": (
launched_profile.model_dump(mode="json")
if launched_profile is not None
"launched_agent_profile": (
launched_agent_profile.model_dump(mode="json")
if launched_agent_profile is not None
else None
),
},
Expand All @@ -783,7 +786,7 @@ async def _start_conversation(
else:
stored = StoredConversation(
id=conversation_id,
launched_profile=launched_profile,
launched_agent_profile=launched_agent_profile,
**request_data,
)
event_service = await self._start_event_service(stored)
Expand Down
12 changes: 7 additions & 5 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
TextContent as TextContent,
)
from openhands.sdk.llm.utils.metrics import MetricsSnapshot
from openhands.sdk.profiles.agent_profile import LaunchedProfile as LaunchedProfile
from openhands.sdk.profiles.agent_profile import (
LaunchedAgentProfile as LaunchedAgentProfile,
)
from openhands.sdk.secret import SecretSource
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.confirmation_policy import (
Expand Down Expand Up @@ -79,7 +81,7 @@ class StoredConversation(StartConversationRequest):
Extends StartConversationRequest with server-assigned fields.
"""

# agent_profile_id is resolved into launched_profile at creation; exclude from
# agent_profile_id is resolved into launched_agent_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)

Expand All @@ -90,7 +92,7 @@ class StoredConversation(StartConversationRequest):
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(
launched_agent_profile: LaunchedAgentProfile | None = Field(
default=None,
description=(
"Provenance snapshot of the agent profile that launched this "
Expand Down Expand Up @@ -248,14 +250,14 @@ class _ConversationInfoBase(BaseModel):
"and before the conversation has started a session."
),
)
launched_profile: LaunchedProfile | None = Field(
launched_agent_profile: LaunchedAgentProfile | 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."
"which agent profile is current without fragile settings-comparison."
),
)

Expand Down
4 changes: 2 additions & 2 deletions openhands-sdk/openhands/sdk/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
ACPAgentProfile,
AgentProfile,
AgentProfileBase,
LaunchedProfile,
LaunchedAgentProfile,
OpenHandsAgentProfile,
ProfileVerificationSettings,
validate_agent_profile,
Expand Down Expand Up @@ -38,7 +38,7 @@
"AgentProfileDiagnostics",
"AgentProfileStore",
"DanglingMcpServerRef",
"LaunchedProfile",
"LaunchedAgentProfile",
"OpenHandsAgentProfile",
"ProfileLimitExceeded",
"ProfileNotFound",
Expand Down
14 changes: 7 additions & 7 deletions openhands-sdk/openhands/sdk/profiles/agent_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,20 +209,20 @@ class ACPAgentProfile(AgentProfileBase):
)


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

Stored on ``StoredConversation`` and projected onto ``ConversationInfo`` so
ts-client ``deriveSwitchPlan`` can identify which profile is current without
fragile settings-comparison. See #3720.
ts-client ``deriveSwitchPlan`` can identify which agent profile is current
without fragile settings-comparison. See #3720.
"""

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


Expand Down
84 changes: 44 additions & 40 deletions tests/agent_server/test_agent_profile_conv_start.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Tests for agent_profile_id at conversation start + LaunchedProfile provenance.
"""Tests for agent_profile_id at conversation start + LaunchedAgentProfile provenance.

Covers:
- start-from-profile (OpenHands + ACP paths)
- mutual-exclusivity validation (SDK layer)
- unknown-id 404 / dangling-ref 422 (router layer)
- LaunchedProfile provenance round-trip through StoredConversation
- LaunchedAgentProfile provenance round-trip through StoredConversation
"""

from __future__ import annotations
Expand All @@ -26,7 +26,7 @@
from openhands.agent_server.event_service import EventService
from openhands.agent_server.models import (
ConversationInfo,
LaunchedProfile,
LaunchedAgentProfile,
StartConversationRequest,
StoredConversation,
)
Expand Down Expand Up @@ -190,7 +190,7 @@ def test_openhands_profile_resolves_to_agent_and_stamps_launched(self):
)

assert result_agent is agent
assert launched.profile_id == profile.id
assert launched.agent_profile_id == profile.id
assert launched.revision == profile.revision

def test_dangling_mcp_server_ref_propagates(self):
Expand Down Expand Up @@ -239,7 +239,7 @@ def test_acp_profile_resolves_to_acp_agent(self):
)

assert result_agent is acp_agent
assert launched.profile_id == profile.id
assert launched.agent_profile_id == profile.id
assert launched.revision == profile.revision


Expand All @@ -250,11 +250,15 @@ def test_acp_profile_resolves_to_acp_agent(self):

class TestConversationServiceStartFromProfile:
@pytest.mark.asyncio
async def test_start_from_profile_stamps_launched_profile_on_stored(self, tmp_path):
"""_start_conversation passes launched_profile to StoredConversation."""
async def test_start_from_profile_stamps_launched_agent_profile_on_stored(
self, tmp_path
):
"""_start_conversation passes launched_agent_profile to StoredConversation."""
profile_id = uuid4()
agent = _make_agent()
launched_profile = LaunchedProfile(profile_id=profile_id, revision=5)
launched_agent_profile = LaunchedAgentProfile(
agent_profile_id=profile_id, revision=5
)
request = StartConversationRequest(
agent_profile_id=profile_id,
workspace=LocalWorkspace(working_dir=str(tmp_path)),
Expand All @@ -270,7 +274,7 @@ async def test_start_from_profile_stamps_launched_profile_on_stored(self, tmp_pa

with patch(
"openhands.agent_server.conversation_service._resolve_agent_from_profile",
return_value=(agent, launched_profile),
return_value=(agent, launched_agent_profile),
):
service = ConversationService(conversations_dir=tmp_path)
service._event_services = {}
Expand All @@ -281,7 +285,7 @@ async def test_start_from_profile_stamps_launched_profile_on_stored(self, tmp_pa
mock_es = AsyncMock(spec=EventService)
mock_es.get_state.return_value = mock_state
mock_es.stored = MagicMock(
launched_profile=launched_profile,
launched_agent_profile=launched_agent_profile,
client_tools=[],
title=None,
metrics=None,
Expand All @@ -299,9 +303,9 @@ async def capture_start(stored):

stored = captured.get("stored")
assert stored is not None, "StoredConversation was not captured"
assert stored.launched_profile is not None
assert stored.launched_profile.profile_id == profile_id
assert stored.launched_profile.revision == 5
assert stored.launched_agent_profile is not None
assert stored.launched_agent_profile.agent_profile_id == profile_id
assert stored.launched_agent_profile.revision == 5
# The resolved agent (not None) must be present
assert stored.agent is not None

Expand Down Expand Up @@ -385,46 +389,46 @@ def test_dangling_mcp_server_ref_returns_422(


# ---------------------------------------------------------------------------
# Provenance round-trip: LaunchedProfile survives serialization
# Provenance round-trip: LaunchedAgentProfile survives serialization
# ---------------------------------------------------------------------------


class TestLaunchedProfileRoundTrip:
def test_launched_profile_survives_stored_conversation_round_trip(self):
"""LaunchedProfile survives model_dump/model_validate round-trip."""
class TestLaunchedAgentProfileRoundTrip:
def test_launched_agent_profile_survives_stored_conversation_round_trip(self):
"""LaunchedAgentProfile survives model_dump/model_validate round-trip."""
profile_id = uuid4()
lp = LaunchedProfile(profile_id=profile_id, revision=7)
lp = LaunchedAgentProfile(agent_profile_id=profile_id, revision=7)
stored = StoredConversation(
id=uuid4(),
agent=_make_agent(),
workspace=LocalWorkspace(working_dir="/tmp"),
launched_profile=lp,
launched_agent_profile=lp,
)

dumped = stored.model_dump(mode="json")
assert dumped["launched_profile"] is not None
assert dumped["launched_profile"]["profile_id"] == str(profile_id)
assert dumped["launched_profile"]["revision"] == 7
assert dumped["launched_agent_profile"] is not None
assert dumped["launched_agent_profile"]["agent_profile_id"] == str(profile_id)
assert dumped["launched_agent_profile"]["revision"] == 7

reloaded = StoredConversation.model_validate({"id": str(stored.id), **dumped})
assert reloaded.launched_profile is not None
assert reloaded.launched_profile.profile_id == profile_id
assert reloaded.launched_profile.revision == 7
assert reloaded.launched_agent_profile is not None
assert reloaded.launched_agent_profile.agent_profile_id == profile_id
assert reloaded.launched_agent_profile.revision == 7

def test_stored_conversation_without_profile_has_none(self):
stored = StoredConversation(
id=uuid4(),
agent=_make_agent(),
workspace=LocalWorkspace(working_dir="/tmp"),
)
assert stored.launched_profile is None
assert stored.launched_agent_profile is None

def test_agent_profile_id_excluded_from_stored_conversation_persistence(self):
"""Regression: agent_profile_id must NOT appear in StoredConversation payload.

StartConversationRequest.model_dump() includes agent_profile_id for HTTP
transport. _start_conversation excludes it before building StoredConversation
(the field is resolved into launched_profile); this test verifies that a
(the field is resolved into launched_agent_profile); this test verifies that a
StoredConversation built from a resolved request contains neither the raw
profile UUID nor re-exposes it.
"""
Expand All @@ -443,9 +447,9 @@ def test_agent_profile_id_excluded_from_stored_conversation_persistence(self):
dumped = stored.model_dump(mode="json")
assert "agent_profile_id" not in dumped

def test_launched_profile_in_conversation_info(self):
def test_launched_agent_profile_in_conversation_info(self):
profile_id = uuid4()
lp = LaunchedProfile(profile_id=profile_id, revision=3)
lp = LaunchedAgentProfile(agent_profile_id=profile_id, revision=3)
now = datetime.now(UTC)
info = ConversationInfo(
id=uuid4(),
Expand All @@ -454,11 +458,11 @@ def test_launched_profile_in_conversation_info(self):
execution_status=ConversationExecutionStatus.IDLE,
created_at=now,
updated_at=now,
launched_profile=lp,
launched_agent_profile=lp,
)
assert info.launched_profile is not None
assert info.launched_profile.profile_id == profile_id
assert info.launched_profile.revision == 3
assert info.launched_agent_profile is not None
assert info.launched_agent_profile.agent_profile_id == profile_id
assert info.launched_agent_profile.revision == 3

def test_conversation_info_without_profile_is_none(self):
now = datetime.now(UTC)
Expand All @@ -470,22 +474,22 @@ def test_conversation_info_without_profile_is_none(self):
created_at=now,
updated_at=now,
)
assert info.launched_profile is None
assert info.launched_agent_profile is None

def test_launched_profile_survives_json_serialization(self, tmp_path):
def test_launched_agent_profile_survives_json_serialization(self, tmp_path):
"""Simulate meta.json round-trip: dump → write → read → validate."""
profile_id = uuid4()
lp = LaunchedProfile(profile_id=profile_id, revision=5)
lp = LaunchedAgentProfile(agent_profile_id=profile_id, revision=5)
stored = StoredConversation(
id=uuid4(),
agent=_make_agent(),
workspace=LocalWorkspace(working_dir=str(tmp_path)),
launched_profile=lp,
launched_agent_profile=lp,
)
meta_file = tmp_path / "meta.json"
meta_file.write_text(stored.model_dump_json())

reloaded = StoredConversation.model_validate_json(meta_file.read_text())
assert reloaded.launched_profile is not None
assert reloaded.launched_profile.profile_id == profile_id
assert reloaded.launched_profile.revision == 5
assert reloaded.launched_agent_profile is not None
assert reloaded.launched_agent_profile.agent_profile_id == profile_id
assert reloaded.launched_agent_profile.revision == 5
Loading