Skip to content
Open
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 @@ -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.

Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions openhands-sdk/openhands/sdk/llm/fallback_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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 "
Expand Down
38 changes: 38 additions & 0 deletions openhands-sdk/openhands/sdk/llm/llm_profile_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
35 changes: 30 additions & 5 deletions openhands-sdk/openhands/sdk/subagent/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
"""
Expand All @@ -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,
Expand All @@ -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.

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions openhands-tools/openhands/tools/delegate/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions openhands-tools/openhands/tools/task/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion tests/sdk/conversation/local/test_fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
Loading
Loading