diff --git a/openhands-sdk/openhands/sdk/agent/acp_agent.py b/openhands-sdk/openhands/sdk/agent/acp_agent.py index c24efd1d09..af351e2fce 100644 --- a/openhands-sdk/openhands/sdk/agent/acp_agent.py +++ b/openhands-sdk/openhands/sdk/agent/acp_agent.py @@ -116,9 +116,17 @@ _TERMINAL_TOOL_CALL_STATUSES: frozenset[str] = frozenset({"completed", "failed"}) +# Stable identifier stamped onto the sentinel LLM so downstream code +# (e.g. title_utils) can detect "this LLM cannot be called" without +# relying on the model name — which we overwrite with the real model +# once ``acp_model`` is known, so logs and serialized state show the +# actual model rather than "acp-managed". +ACP_SENTINEL_USAGE_ID = "acp-managed" + + def _make_dummy_llm() -> LLM: """Create a dummy LLM that should never be called directly.""" - return LLM(model="acp-managed") + return LLM(model="acp-managed", usage_id=ACP_SENTINEL_USAGE_ID) # --------------------------------------------------------------------------- @@ -657,10 +665,13 @@ class ACPAgent(AgentBase): def model_post_init(self, __context: object) -> None: super().model_post_init(__context) - # Propagate the actual model name to metrics so that cost/token - # entries are attributed to the real model, not the sentinel - # "acp-managed" placeholder. + # Propagate the actual model name to the sentinel LLM and its + # metrics so that logs, serialized state, and cost/token entries + # show the real model instead of the "acp-managed" placeholder. + # The ACP-sentinel marker lives on ``llm.usage_id`` and is + # independent of the model name. if self.acp_model: + self.llm.model = self.acp_model self.llm.metrics.model_name = self.acp_model if self.llm.metrics.accumulated_token_usage is not None: self.llm.metrics.accumulated_token_usage.model = self.acp_model diff --git a/openhands-sdk/openhands/sdk/conversation/title_utils.py b/openhands-sdk/openhands/sdk/conversation/title_utils.py index c4ced5d1c7..49334f54a3 100644 --- a/openhands-sdk/openhands/sdk/conversation/title_utils.py +++ b/openhands-sdk/openhands/sdk/conversation/title_utils.py @@ -161,7 +161,10 @@ def generate_title_from_message( message: str, llm: LLM | None = None, max_length: int = 50 ) -> str: """Generate a title from an already-extracted user message.""" - llm_to_use = None if llm and llm.model == "acp-managed" else llm + # Skip the ACP sentinel LLM — it has no credentials and cannot be + # called. Detected via ``usage_id`` so the real model name can still + # appear in logs and serialized state. + llm_to_use = None if llm and llm.usage_id == "acp-managed" else llm if llm_to_use: llm_title = generate_title_with_llm(message, llm_to_use, max_length) diff --git a/tests/agent_server/test_conversation_service.py b/tests/agent_server/test_conversation_service.py index 9b8d28a032..11d7ae4080 100644 --- a/tests/agent_server/test_conversation_service.py +++ b/tests/agent_server/test_conversation_service.py @@ -1660,10 +1660,11 @@ def _make_service( title: str | None = None, title_llm_profile: str | None = None, llm_model: str = "gpt-4o", + llm_usage_id: str = "test-llm", ) -> AsyncMock: stored = StoredConversation( id=uuid4(), - agent=Agent(llm=LLM(model=llm_model, usage_id="test-llm"), tools=[]), + agent=Agent(llm=LLM(model=llm_model, usage_id=llm_usage_id), tools=[]), workspace=LocalWorkspace(working_dir="workspace/project"), confirmation_policy=NeverConfirm(), initial_message=None, @@ -1888,7 +1889,7 @@ async def test_autotitle_handles_profile_load_value_error(self): @pytest.mark.asyncio async def test_autotitle_falls_back_for_acp_managed_llm(self): """ACP-managed agents with no title profile → truncation fallback.""" - service = self._make_service(llm_model="acp-managed") + service = self._make_service(llm_usage_id="acp-managed") subscriber = AutoTitleSubscriber(service=service) await subscriber(self._user_message_event("Fix the login bug")) diff --git a/tests/sdk/agent/test_acp_agent.py b/tests/sdk/agent/test_acp_agent.py index 4e9fafb485..b7e6c8260b 100644 --- a/tests/sdk/agent/test_acp_agent.py +++ b/tests/sdk/agent/test_acp_agent.py @@ -105,6 +105,18 @@ def test_acp_model_propagated_to_metrics(self): agent.llm.metrics.accumulated_token_usage.model == "gemini-3-flash-preview" ) + def test_acp_model_propagated_to_llm_model(self): + """acp_model overrides the sentinel model name so logs/state show + the real model. The ACP-sentinel marker lives on usage_id.""" + agent = _make_agent(acp_model="claude-opus-4-6") + assert agent.llm.model == "claude-opus-4-6" + assert agent.llm.usage_id == "acp-managed" + + def test_sentinel_usage_id_without_acp_model(self): + agent = _make_agent() + assert agent.llm.model == "acp-managed" + assert agent.llm.usage_id == "acp-managed" + def test_no_acp_model_keeps_sentinel(self): """Without acp_model, metrics.model_name remains the sentinel value.""" agent = _make_agent()