diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 7b93bc5a10..030b856d35 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -513,6 +513,7 @@ def _register_agent_definitions( agent_defs: list["AgentDefinition"], *, context: str, + cipher: Cipher | None, ) -> None: """Register agent definitions into the subagent registry. @@ -527,7 +528,7 @@ def _register_agent_definitions( registered = 0 for agent_def in agent_defs: try: - factory = agent_definition_to_factory(agent_def) + factory = agent_definition_to_factory(agent_def, cipher=cipher) register_agent_if_absent( name=agent_def.name, factory_func=factory, @@ -1045,6 +1046,7 @@ def _prepare_persisted_runtime(self, stored: StoredConversation) -> None: _register_agent_definitions( stored.agent_definitions, context=f"resuming conversation {stored.id}", + cipher=self.cipher, ) async def _get_or_load_event_service( @@ -1560,6 +1562,7 @@ async def _start_conversation( _register_agent_definitions( request.agent_definitions, context=f"conversation {conversation_id}", + cipher=self.cipher, ) # Plugin loading is now handled lazily by LocalConversation. diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index ffe3e7b864..2e3d4c78d8 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -1213,6 +1213,7 @@ def _ensure_plugins_loaded(self) -> None: register_plugin_agents( agents=all_plugin_agents, work_dir=self.workspace.working_dir, + cipher=self._cipher, ) # Combine explicit hook_config with plugin hooks @@ -1458,6 +1459,7 @@ def load_plugin(self, plugin_ref: str) -> None: register_plugin_agents( agents=plugin.agents, work_dir=self.workspace.working_dir, + cipher=self._cipher, ) if plugin.hooks and not plugin.hooks.is_empty(): self._merge_runtime_plugin_hooks(plugin.hooks) @@ -1496,7 +1498,7 @@ def _register_file_based_agents(self) -> None: then `~/.openhands/agents/*.md`) """ # register project-level and then user-level file-based agents - register_file_agents(self.workspace.working_dir) + register_file_agents(self.workspace.working_dir, cipher=self._cipher) def _ensure_agent_ready(self) -> None: """Ensure the agent is fully initialized with plugins and agents loaded. diff --git a/openhands-sdk/openhands/sdk/subagent/registry.py b/openhands-sdk/openhands/sdk/subagent/registry.py index 6350877352..2d3f12ae93 100644 --- a/openhands-sdk/openhands/sdk/subagent/registry.py +++ b/openhands-sdk/openhands/sdk/subagent/registry.py @@ -42,6 +42,7 @@ def create_security_expert(llm): if TYPE_CHECKING: from openhands.sdk.agent.agent import Agent from openhands.sdk.llm.llm import LLM + from openhands.sdk.utils.cipher import Cipher logger = get_logger(__name__) @@ -160,6 +161,8 @@ def _get_profile_store(profile_store_dir: str | None) -> LLMProfileStore: def agent_definition_to_factory( agent_def: AgentDefinition, work_dir: str | Path | None = None, + *, + cipher: "Cipher | None" = None, ) -> Callable[["LLM"], "Agent"]: """Create an agent factory closure from an `AgentDefinition`. @@ -181,6 +184,7 @@ def agent_definition_to_factory( agent_def: The agent definition to convert. work_dir: Project directory for resolving skill names. If None, only user-level skills are searched. + cipher: Cipher for decrypting secrets in the selected LLM profile. Raises: ValueError: If a tool or skill is not found. @@ -225,7 +229,7 @@ def _factory(llm: "LLM") -> "Agent": f"Available profiles: {available_profiles}" ) - llm = store.load(profile_name) + llm = store.load(profile_name, cipher=cipher) # the system prompt of the subagent is added as a suffix of the # main system prompt @@ -285,7 +289,11 @@ def _factory(llm: "LLM") -> "Agent": return _factory -def register_file_agents(work_dir: str | Path) -> list[str]: +def register_file_agents( + work_dir: str | Path, + *, + cipher: "Cipher | None" = None, +) -> list[str]: """Load and register file-based agents from project-level `.agents/agents` and `.openhands/agents`, and user-level `~/.agents/agents` and `~/.openhands/agents` directories. @@ -317,7 +325,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, work_dir=work_dir) + factory = agent_definition_to_factory( + agent_def, + work_dir=work_dir, + cipher=cipher, + ) was_registered = register_agent_if_absent( name=agent_def.name, factory_func=factory, @@ -336,6 +348,8 @@ def register_file_agents(work_dir: str | Path) -> list[str]: def register_plugin_agents( agents: list[AgentDefinition], work_dir: str | Path | None = None, + *, + cipher: "Cipher | None" = None, ) -> list[str]: """Register plugin-provided agent definitions into the delegate registry. @@ -354,7 +368,11 @@ def register_plugin_agents( """ registered: list[str] = [] for agent_def in agents: - factory = agent_definition_to_factory(agent_def, work_dir=work_dir) + factory = agent_definition_to_factory( + agent_def, + work_dir=work_dir, + cipher=cipher, + ) was_registered = register_agent_if_absent( name=agent_def.name, factory_func=factory, diff --git a/tests/sdk/subagent/test_subagent_registry.py b/tests/sdk/subagent/test_subagent_registry.py index 8c4402bfac..51f770b308 100644 --- a/tests/sdk/subagent/test_subagent_registry.py +++ b/tests/sdk/subagent/test_subagent_registry.py @@ -21,6 +21,7 @@ register_plugin_agents, ) from openhands.sdk.subagent.schema import AgentDefinition +from openhands.sdk.utils.cipher import Cipher def setup_function() -> None: @@ -591,6 +592,28 @@ def test_agent_definition_to_factory_model_profile_custom_store(tmp_path: Path) assert agent.llm.metrics is not parent_llm.metrics +def test_agent_definition_to_factory_decrypts_model_profile(tmp_path: Path) -> None: + """Encrypted model profiles are decrypted for file-based sub-agents.""" + cipher = Cipher("test-secret") + store = LLMProfileStore(base_dir=tmp_path) + profile_llm = LLM( + model="gpt-4o-mini", + api_key=SecretStr("profile-key"), + usage_id="profile-llm", + ) + store.save("encrypted-profile", profile_llm, include_secrets=True, cipher=cipher) + agent_def = AgentDefinition( + name="encrypted-profile-agent", + model="encrypted-profile", + profile_store_dir=str(tmp_path), + ) + + agent = agent_definition_to_factory(agent_def, cipher=cipher)(_make_test_llm()) + + assert isinstance(agent.llm.api_key, SecretStr) + assert agent.llm.api_key.get_secret_value() == "profile-key" + + def test_agent_definition_to_factory_profile_store_dir(tmp_path: Path) -> None: """profile_store_dir on AgentDefinition is used by the factory.""" store = LLMProfileStore(base_dir=tmp_path)