diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index f884562ac9..0e11509da7 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -21,7 +21,7 @@ ConversationInfo, ConversationPage, ConversationSortOrder, - LaunchedProfile, + LaunchedAgentProfile, StartConversationRequest, StoredConversation, UpdateConversationRequest, @@ -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). @@ -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 @@ -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, ) @@ -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. @@ -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, @@ -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", @@ -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 ), }, @@ -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) diff --git a/openhands-agent-server/openhands/agent_server/models.py b/openhands-agent-server/openhands/agent_server/models.py index 244dcaa0d7..132067d114 100644 --- a/openhands-agent-server/openhands/agent_server/models.py +++ b/openhands-agent-server/openhands/agent_server/models.py @@ -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 ( @@ -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) @@ -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 " @@ -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." ), ) diff --git a/openhands-sdk/openhands/sdk/profiles/__init__.py b/openhands-sdk/openhands/sdk/profiles/__init__.py index 5339c96be4..3000f48973 100644 --- a/openhands-sdk/openhands/sdk/profiles/__init__.py +++ b/openhands-sdk/openhands/sdk/profiles/__init__.py @@ -5,7 +5,7 @@ ACPAgentProfile, AgentProfile, AgentProfileBase, - LaunchedProfile, + LaunchedAgentProfile, OpenHandsAgentProfile, ProfileVerificationSettings, validate_agent_profile, @@ -38,7 +38,7 @@ "AgentProfileDiagnostics", "AgentProfileStore", "DanglingMcpServerRef", - "LaunchedProfile", + "LaunchedAgentProfile", "OpenHandsAgentProfile", "ProfileLimitExceeded", "ProfileNotFound", diff --git a/openhands-sdk/openhands/sdk/profiles/agent_profile.py b/openhands-sdk/openhands/sdk/profiles/agent_profile.py index 27bc5d6097..1b72e1142e 100644 --- a/openhands-sdk/openhands/sdk/profiles/agent_profile.py +++ b/openhands-sdk/openhands/sdk/profiles/agent_profile.py @@ -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.", ) diff --git a/tests/agent_server/test_agent_profile_conv_start.py b/tests/agent_server/test_agent_profile_conv_start.py index df90bccb1a..5ce41474b3 100644 --- a/tests/agent_server/test_agent_profile_conv_start.py +++ b/tests/agent_server/test_agent_profile_conv_start.py @@ -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 @@ -26,7 +26,7 @@ from openhands.agent_server.event_service import EventService from openhands.agent_server.models import ( ConversationInfo, - LaunchedProfile, + LaunchedAgentProfile, StartConversationRequest, StoredConversation, ) @@ -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): @@ -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 @@ -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)), @@ -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 = {} @@ -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, @@ -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 @@ -385,31 +389,31 @@ 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( @@ -417,14 +421,14 @@ def test_stored_conversation_without_profile_has_none(self): 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. """ @@ -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(), @@ -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) @@ -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