diff --git a/openhands-agent-server/openhands/agent_server/conversation_service.py b/openhands-agent-server/openhands/agent_server/conversation_service.py index 4f9abac659..b68b395bb9 100644 --- a/openhands-agent-server/openhands/agent_server/conversation_service.py +++ b/openhands-agent-server/openhands/agent_server/conversation_service.py @@ -514,6 +514,7 @@ def _register_agent_definitions( agent_defs: list["AgentDefinition"], *, context: str, + cipher: Cipher | None, ) -> None: """Register agent definitions into the subagent registry. @@ -528,7 +529,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, @@ -1054,6 +1055,7 @@ def _prepare_persisted_runtime(self, stored: StoredConversation) -> None: _register_agent_definitions( stored.agent_definitions, context=f"resuming conversation {stored.id}", + cipher=self.cipher, ) def _get_conversation_lock(self, conversation_id: UUID) -> asyncio.Lock: @@ -1615,6 +1617,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 c5146c19d3..be8282373e 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -406,6 +406,7 @@ def __init__( if recovered_specs: register_client_tools(recovered_specs) self.agent = agent + self._cipher = cipher self._bind_conversation_context(self.agent.llm) @@ -486,8 +487,6 @@ def _default_callback(e): # This ensures plugins are loaded before agent initialization self.llm_registry = LLMRegistry() self._profile_store = LLMProfileStore(profile_store_dir) - self._cipher = cipher - # Seed agent_context.secrets into the registry for every agent (regular # and ACP), covering callers that skip create_request() — canvas / # TypeScript, or the server-side agent_settings -> create_agent fold. @@ -835,6 +834,7 @@ def fork( stuck_detection=self._stuck_detector is not None, visualizer=type(self._visualizer) if self._visualizer else None, delete_on_close=self.delete_on_close, + cipher=self._cipher, tags=tags, ) @@ -1214,6 +1214,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 @@ -1459,6 +1460,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) @@ -1497,7 +1499,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. @@ -1603,6 +1605,8 @@ def _bind_conversation_context(self, llm: LLM) -> None: See #3443 for background. """ llm._call_context = self.get_llm_call_context() + if llm.fallback_strategy is not None: + llm.fallback_strategy._bind_cipher(self._cipher) def _condenser_for_switched_llm( self, diff --git a/openhands-sdk/openhands/sdk/llm/fallback_strategy.py b/openhands-sdk/openhands/sdk/llm/fallback_strategy.py index e1793e767a..e35256cffb 100644 --- a/openhands-sdk/openhands/sdk/llm/fallback_strategy.py +++ b/openhands-sdk/openhands/sdk/llm/fallback_strategy.py @@ -15,13 +15,17 @@ from pydantic import BaseModel, Field, PrivateAttr from openhands.sdk.llm.exceptions import LLMNoResponseError -from openhands.sdk.llm.llm_profile_store import LLMProfileStore +from openhands.sdk.llm.llm_profile_store import ( + LLMProfileStore, + ProfileDecryptionError, +) from openhands.sdk.logger import get_logger if TYPE_CHECKING: from openhands.sdk.llm.llm_response import LLMResponse from openhands.sdk.llm.utils.metrics import Metrics + from openhands.sdk.utils.cipher import Cipher logger = get_logger(__name__) @@ -55,6 +59,11 @@ class FallbackStrategy(BaseModel): # Private: lazily resolved LLM instances _resolved: list[Any] | None = PrivateAttr(default=None) + _cipher: Cipher | None = PrivateAttr(default=None) + + def _bind_cipher(self, cipher: Cipher | None) -> None: + """Bind the conversation cipher used for lazy profile resolution.""" + self._cipher = cipher def should_fallback(self, error: Exception) -> bool: """Whether this error type is eligible for fallback.""" @@ -139,9 +148,14 @@ def _iter_fallbacks(self) -> Generator[Any]: remaining_names = self.fallback_llms[len(self._resolved) :] for name in remaining_names: try: - fb = self._profile_store.load(name) + fb = self._profile_store._load_for_execution( + name, + cipher=self._cipher, + ) self._resolved.append(fb) yield fb + except ProfileDecryptionError: + raise except (FileNotFoundError, ValueError) as exc: logger.error( "[Fallback Strategy] Failed to load " diff --git a/openhands-sdk/openhands/sdk/llm/llm_profile_store.py b/openhands-sdk/openhands/sdk/llm/llm_profile_store.py index 5001e4b9c4..4542bc7290 100644 --- a/openhands-sdk/openhands/sdk/llm/llm_profile_store.py +++ b/openhands-sdk/openhands/sdk/llm/llm_profile_store.py @@ -16,6 +16,7 @@ canonicalize_openhands_llm_payload, ) from openhands.sdk.logger import get_logger +from openhands.sdk.utils.cipher import FERNET_TOKEN_PREFIX from openhands.sdk.utils.pydantic_secrets import REDACTED_SECRET_VALUE @@ -40,6 +41,10 @@ class ProfileLimitExceeded(Exception): """Raised when saving would exceed the configured profile limit.""" +class ProfileDecryptionError(ValueError): + """Raised when an encrypted profile secret cannot be decrypted.""" + + def _api_key_present(llm: LLM) -> bool: """True when ``llm`` carries a non-empty, non-redacted API key.""" from pydantic import SecretStr @@ -358,6 +363,39 @@ def _resolve_provider_connection( updates["api_key"] = SecretStr(api_key) return llm.model_copy(update=updates) + def _load_for_execution( + self, + name: str, + *, + cipher: Cipher | None = None, + ) -> LLM: + """Load a profile and reject any secret that remained encrypted.""" + from openhands.sdk.llm.llm import LLM_SECRET_FIELDS + + profile_path = self._get_profile_path(name) + llm = self.load(name, cipher=cipher) + profile_name = name.removesuffix(".json") + with self._acquire_lock(): + stored_profile = json.loads(profile_path.read_text()) + + loaded_profile = llm.model_dump(mode="json", context={"expose_secrets": True}) + for field in LLM_SECRET_FIELDS: + stored_value = stored_profile.get(field) + loaded_value = loaded_profile[field] + if ( + isinstance(loaded_value, str) + and loaded_value.startswith(FERNET_TOKEN_PREFIX) + ) or ( + isinstance(stored_value, str) + and stored_value.startswith(FERNET_TOKEN_PREFIX) + and loaded_value is None + ): + raise ProfileDecryptionError( + f"Could not decrypt secret '{field}' for profile " + f"'{profile_name}'. Use the cipher that encrypted the profile." + ) + return llm + def delete(self, name: str) -> None: """Delete an existing profile. diff --git a/openhands-sdk/openhands/sdk/subagent/registry.py b/openhands-sdk/openhands/sdk/subagent/registry.py index 6350877352..d08227db7d 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__) @@ -132,7 +133,9 @@ def register_agent_if_absent( no-ops when an agent with *name* is already registered, instead of raising `ValueError`. This is used by file-based and plugin-based agent loading to gracefully skip conflicts with programmatically - registered agents. + registered agents. Because the first registration wins, values captured + by ``factory_func`` (including a profile cipher) are pinned by the first + caller for that name. See `register_agent` for full parameter documentation. @@ -160,6 +163,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 +186,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 +231,7 @@ def _factory(llm: "LLM") -> "Agent": f"Available profiles: {available_profiles}" ) - llm = store.load(profile_name) + llm = store._load_for_execution(profile_name, cipher=cipher) # the system prompt of the subagent is added as a suffix of the # main system prompt @@ -285,7 +291,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. @@ -295,6 +305,10 @@ def register_file_agents(work_dir: str | Path) -> list[str]: Does not overwrite agents already registered programmatically or by plugins. + Args: + work_dir: Project directory used to discover agent definitions. + cipher: Cipher for decrypting secrets in selected LLM profiles. + Returns: List of agent names that were actually registered. """ @@ -317,7 +331,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 +354,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. @@ -348,13 +368,18 @@ def register_plugin_agents( agents: Agent definitions collected from loaded plugins. work_dir: Project directory for resolving skill names in agent definitions. If None, only user-level skills are searched. + cipher: Cipher for decrypting secrets in selected LLM profiles. Returns: List of agent names that were actually registered. """ 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/openhands-tools/openhands/tools/delegate/impl.py b/openhands-tools/openhands/tools/delegate/impl.py index c72a77862f..db630d892c 100644 --- a/openhands-tools/openhands/tools/delegate/impl.py +++ b/openhands-tools/openhands/tools/delegate/impl.py @@ -220,6 +220,7 @@ def _spawn_agents(self, action: "DelegateAction") -> DelegateObservation: "visualizer": sub_visualizer, "hook_config": factory.definition.hooks, "persistence_dir": subagents_persistence_dir, + "cipher": parent_conversation._cipher, } if factory.definition.max_iteration_per_run is not None: diff --git a/openhands-tools/openhands/tools/task/manager.py b/openhands-tools/openhands/tools/task/manager.py index a8b52a1d60..697084af6f 100644 --- a/openhands-tools/openhands/tools/task/manager.py +++ b/openhands-tools/openhands/tools/task/manager.py @@ -220,6 +220,7 @@ def _resume_task(self, resume: str, subagent_type: str) -> Task: conversation_id=conversation_id, hook_config=factory.definition.hooks, delete_on_close=True, + cipher=self.parent_conversation._cipher, observability_metadata=self._delegate_observability_metadata( task_id=resume, subagent_type=subagent_type, link=link ), @@ -322,6 +323,7 @@ def _get_conversation( max_budget_per_run=max_budget_per_run, hook_config=hook_config, delete_on_close=True, + cipher=parent._cipher, prompt_cache_key=str(parent.state.id), observability_metadata=self._delegate_observability_metadata( task_id=task_id, subagent_type=subagent_type, link=link diff --git a/tests/sdk/conversation/local/test_fork.py b/tests/sdk/conversation/local/test_fork.py index 3057831077..e7e58e24a8 100644 --- a/tests/sdk/conversation/local/test_fork.py +++ b/tests/sdk/conversation/local/test_fork.py @@ -16,6 +16,7 @@ from openhands.sdk.event.llm_convertible import MessageEvent, SystemPromptEvent from openhands.sdk.llm import LLM, Message, TextContent from openhands.sdk.tool import Action, Observation, ToolDefinition, ToolExecutor +from openhands.sdk.utils.cipher import Cipher def _agent() -> Agent: @@ -72,12 +73,19 @@ def create(cls, *args, **kwargs) -> Sequence[Self]: def test_fork_creates_new_id(): """Forked conversation must have a distinct ID.""" + cipher = Cipher("test-secret") with tempfile.TemporaryDirectory() as tmpdir: - src = Conversation(agent=_agent(), persistence_dir=tmpdir, workspace=tmpdir) + src = LocalConversation( + agent=_agent(), + persistence_dir=tmpdir, + workspace=tmpdir, + cipher=cipher, + ) fork = src.fork() assert fork.id != src.id assert isinstance(fork.id, uuid.UUID) + assert fork._cipher is cipher def test_fork_with_explicit_id(): diff --git a/tests/sdk/llm/test_llm_fallback.py b/tests/sdk/llm/test_llm_fallback.py index f48fb4b732..b3bd549ec8 100644 --- a/tests/sdk/llm/test_llm_fallback.py +++ b/tests/sdk/llm/test_llm_fallback.py @@ -15,12 +15,19 @@ ) from pydantic import SecretStr +from openhands.sdk import Agent +from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.llm import LLM, FallbackStrategy, Message, TextContent from openhands.sdk.llm.exceptions import ( LLMContextWindowExceedError, LLMServiceUnavailableError, ) from openhands.sdk.llm.llm import LLMCallContext +from openhands.sdk.llm.llm_profile_store import ( + LLMProfileStore, + ProfileDecryptionError, +) +from openhands.sdk.utils.cipher import Cipher def _get_mock_response(content: str = "ok", model: str = "gpt-4o") -> ModelResponse: @@ -313,6 +320,58 @@ def side_effect(**kwargs): assert content.text == "from store" +def test_fallback_profile_uses_bound_cipher(tmp_path): + cipher = Cipher("test-secret") + store = LLMProfileStore(base_dir=tmp_path) + store.save( + "encrypted-fallback", + _get_llm("fallback-model"), + include_secrets=True, + cipher=cipher, + ) + strategy = FallbackStrategy( + fallback_llms=["encrypted-fallback"], + profile_store_dir=tmp_path, + ) + + conversation = LocalConversation( + agent=Agent(llm=_get_llm("primary", fallback_strategy=strategy), tools=[]), + workspace=tmp_path, + cipher=cipher, + visualizer=None, + ) + bound_strategy = conversation.agent.llm.fallback_strategy + assert bound_strategy is not None + [fallback] = list(bound_strategy._iter_fallbacks()) + + assert isinstance(fallback.api_key, SecretStr) + assert fallback.api_key.get_secret_value() == "k" + + unbound_strategy = FallbackStrategy( + fallback_llms=["encrypted-fallback"], + profile_store_dir=tmp_path, + ) + with pytest.raises(ProfileDecryptionError, match="encrypted-fallback"): + list(unbound_strategy._iter_fallbacks()) + + +def test_fallback_skips_profile_with_missing_provider_connection(tmp_path): + store = LLMProfileStore(base_dir=tmp_path) + store.save( + "missing-connection", + LLM(model="gpt-4o-mini", provider_connection_id="deleted"), + ) + store.save("working", _get_llm("fallback-model"), include_secrets=True) + strategy = FallbackStrategy( + fallback_llms=["missing-connection", "working"], + profile_store_dir=tmp_path, + ) + + [fallback] = list(strategy._iter_fallbacks()) + + assert fallback.model == "fallback-model" + + # ========================================================================= # Async error-handling parity tests (acompletion / aresponses) # ========================================================================= diff --git a/tests/sdk/llm/test_llm_profile_store.py b/tests/sdk/llm/test_llm_profile_store.py index 6c429c165a..fc80c83615 100644 --- a/tests/sdk/llm/test_llm_profile_store.py +++ b/tests/sdk/llm/test_llm_profile_store.py @@ -10,8 +10,10 @@ from openhands.sdk.llm import LLM, LLM_PROFILE_SCHEMA_VERSION from openhands.sdk.llm.llm_profile_store import ( LLMProfileStore, + ProfileDecryptionError, ProfileLimitExceeded, ) +from openhands.sdk.utils.cipher import Cipher @pytest.fixture @@ -310,6 +312,24 @@ def test_save_with_secrets( assert "secret-api-key-12345" in content +def test_execution_load_checks_actual_case_variant_path( + profile_store: LLMProfileStore, + sample_llm_with_secrets: LLM, + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile_store.save( + "Prod", + sample_llm_with_secrets, + include_secrets=True, + cipher=Cipher("right-secret"), + ) + stored_path = profile_store.base_dir / "Prod.json" + monkeypatch.setattr(profile_store, "_get_profile_path", lambda _name: stored_path) + + with pytest.raises(ProfileDecryptionError, match="api_key.*prod"): + profile_store._load_for_execution("prod") + + @pytest.mark.parametrize("name", ["my_profile", "my_profile.json"]) def test_load_existing_profile( name: str, profile_store: LLMProfileStore, sample_llm: LLM diff --git a/tests/sdk/subagent/test_subagent_registry.py b/tests/sdk/subagent/test_subagent_registry.py index 8c4402bfac..a6f0ceaebe 100644 --- a/tests/sdk/subagent/test_subagent_registry.py +++ b/tests/sdk/subagent/test_subagent_registry.py @@ -8,7 +8,10 @@ from openhands.sdk import LLM, Agent from openhands.sdk.context.condenser import LLMSummarizingCondenser, NoOpCondenser from openhands.sdk.hooks.config import HookConfig, HookDefinition, HookMatcher -from openhands.sdk.llm.llm_profile_store import LLMProfileStore +from openhands.sdk.llm.llm_profile_store import ( + LLMProfileStore, + ProfileDecryptionError, +) from openhands.sdk.mcp.config import MCPServer, dump_mcp_config from openhands.sdk.subagent.registry import ( _reset_registry_for_tests, @@ -21,6 +24,7 @@ register_plugin_agents, ) from openhands.sdk.subagent.schema import AgentDefinition +from openhands.sdk.utils.cipher import Cipher def setup_function() -> None: @@ -591,6 +595,69 @@ def test_agent_definition_to_factory_model_profile_custom_store(tmp_path: Path) assert agent.llm.metrics is not parent_llm.metrics +def test_register_file_agents_forwards_cipher_to_factory(tmp_path: Path) -> None: + cipher = Cipher("test-secret") + profile_dir = tmp_path / "profiles" + store = LLMProfileStore(base_dir=profile_dir) + store.save( + "encrypted-profile", + LLM(model="gpt-4o-mini", api_key=SecretStr("profile-key")), + include_secrets=True, + cipher=cipher, + ) + agents_dir = tmp_path / ".agents" / "agents" + agents_dir.mkdir(parents=True) + (agents_dir / "encrypted-agent.md").write_text( + "---\n" + "name: encrypted-agent\n" + "model: encrypted-profile\n" + f"profile_store_dir: {profile_dir}\n" + "---\n" + ) + + with patch( + "openhands.sdk.subagent.load.Path.home", return_value=tmp_path / "no_user" + ): + register_file_agents(tmp_path, cipher=cipher) + + agent = get_agent_factory("encrypted-agent").factory_func(_make_test_llm()) + + assert isinstance(agent.llm.api_key, SecretStr) + assert agent.llm.api_key.get_secret_value() == "profile-key" + + +@pytest.mark.parametrize("cipher", [None, Cipher("wrong-secret")]) +@pytest.mark.parametrize( + "secret_field", + ["api_key", "aws_access_key_id", "aws_secret_access_key", "aws_session_token"], +) +def test_agent_definition_to_factory_rejects_undecrypted_secret( + tmp_path: Path, + cipher: Cipher | None, + secret_field: str, +) -> None: + store = LLMProfileStore(base_dir=tmp_path) + profile = LLM.model_validate( + {"model": "gpt-4o-mini", secret_field: "profile-secret"} + ) + store.save( + "encrypted-profile", + profile, + include_secrets=True, + cipher=Cipher("right-secret"), + ) + agent_def = AgentDefinition( + name="encrypted-profile-agent", + model="encrypted-profile", + profile_store_dir=str(tmp_path), + ) + + with pytest.raises( + ProfileDecryptionError, match=rf"{secret_field}.*encrypted-profile" + ): + agent_definition_to_factory(agent_def, cipher=cipher)(_make_test_llm()) + + 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) diff --git a/tests/tools/delegate/test_delegation.py b/tests/tools/delegate/test_delegation.py index f8f10af6ca..184fca56f0 100644 --- a/tests/tools/delegate/test_delegation.py +++ b/tests/tools/delegate/test_delegation.py @@ -17,6 +17,7 @@ register_agent, ) from openhands.sdk.subagent.schema import AgentDefinition +from openhands.sdk.utils.cipher import Cipher from openhands.tools.delegate import ( DelegateExecutor, DelegateObservation, @@ -264,6 +265,8 @@ def test_spawn_disables_streaming_for_sub_agents(): parent_conversation.state.workspace.working_dir = "/tmp" parent_conversation.state.persistence_dir = None parent_conversation._visualizer = None + cipher = Cipher("test-secret") + parent_conversation._cipher = cipher executor = DelegateExecutor() @@ -279,6 +282,7 @@ def test_spawn_disables_streaming_for_sub_agents(): sub_conversation = executor._sub_agents["test_agent"] sub_llm = sub_conversation.agent.llm assert sub_llm.stream is False, "Sub-agent LLM should have streaming disabled" + assert sub_conversation._cipher is cipher # Verify parent LLM still has streaming enabled (wasn't mutated) assert parent_llm.stream is True, "Parent LLM should still have streaming enabled" diff --git a/tests/tools/task/test_task_manager.py b/tests/tools/task/test_task_manager.py index e459f42ebe..8bf68bce6e 100644 --- a/tests/tools/task/test_task_manager.py +++ b/tests/tools/task/test_task_manager.py @@ -15,6 +15,7 @@ register_agent, ) from openhands.sdk.subagent.schema import AgentDefinition +from openhands.sdk.utils.cipher import Cipher from openhands.tools.preset import register_builtins_agents from openhands.tools.task.manager import ( Task, @@ -34,6 +35,7 @@ def _make_llm() -> LLM: def _make_parent_conversation( tmp_path: Path, persistence_dir: str | Path | None = None, + cipher: Cipher | None = None, ) -> LocalConversation: """Create a real (minimal) parent conversation for the manager.""" llm = _make_llm() @@ -44,6 +46,7 @@ def _make_parent_conversation( visualizer=None, delete_on_close=False, persistence_dir=persistence_dir, + cipher=cipher, ) @@ -191,6 +194,22 @@ def test_registers_uuid(self, tmp_path): assert task.id in manager._tasks assert isinstance(manager._tasks[task.id].conversation_id, uuid.UUID) + def test_create_and_resume_inherit_parent_cipher(self, tmp_path): + cipher = Cipher("test-secret") + parent = _make_parent_conversation(tmp_path, cipher=cipher) + manager = TaskManager() + manager._ensure_parent(parent) + register_builtins_agents() + + task = manager._create_task(subagent_type="general-purpose", description=None) + assert task.conversation is not None + assert task.conversation._cipher is cipher + + manager._evict_task(task) + resumed = manager._resume_task(resume=task.id, subagent_type="general-purpose") + assert resumed.conversation is not None + assert resumed.conversation._cipher is cipher + def test_create_task_uses_parent_max_iteration_when_factory_is_none(self, tmp_path): """Fallback to parent's max_iteration_per_run when factory has none.""" register_builtins_agents() diff --git a/tests/tools/test_builtin_agents.py b/tests/tools/test_builtin_agents.py index 80ab9c16ac..40b2f56539 100644 --- a/tests/tools/test_builtin_agents.py +++ b/tests/tools/test_builtin_agents.py @@ -77,6 +77,7 @@ def test_register_builtins_agents_registers_expected_factories( factory = get_agent_factory(name) agent = factory.factory_func(llm) assert isinstance(agent, Agent) + assert agent.llm is llm agent_tool_names[name] = [t.name for t in agent.tools] assert len(agent_tool_names) == len(expected_agents)