Skip to content
Merged
555 changes: 555 additions & 0 deletions openhands-agent-server/openhands/agent_server/agent_profiles_router.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request

from openhands.agent_server.agent_profiles_router import agent_profiles_router
from openhands.agent_server.auth_router import auth_router
from openhands.agent_server.bash_router import bash_router
from openhands.agent_server.bash_service import get_default_bash_event_service
Expand Down Expand Up @@ -352,6 +353,7 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
api_router.include_router(settings_router)
api_router.include_router(workspaces_router)
api_router.include_router(profiles_router)
api_router.include_router(agent_profiles_router)
# /api/auth/* mints workspace cookies and requires the header to bootstrap,
# so it lives under the header-only auth group.
api_router.include_router(auth_router)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class SettingsUpdatePayload(TypedDict, total=False):
conversation_settings_diff: dict[str, Any]
misc_settings_diff: dict[str, Any]
active_profile: str | None
active_agent_profile_id: str | None


def _deep_merge(
Expand Down Expand Up @@ -140,6 +141,14 @@ class PersistedSettings(BaseModel):
default=None,
description="Name of the currently active LLM profile.",
)
active_agent_profile_id: str | None = Field(
default=None,
description=(
"Stable id of the currently active AgentProfile. Distinct from "
"active_profile (the active LLM profile name); additive with a "
"default, so older settings files load with this as None."
),
)
misc_settings: dict[str, Any] = Field(
default_factory=dict,
description=(
Expand All @@ -165,8 +174,8 @@ def llm_api_key_is_set(self) -> bool:
def update(self, payload: SettingsUpdatePayload) -> None:
"""Apply a batch of changes from a nested dict.

Accepts ``agent_settings_diff``, ``conversation_settings_diff``, and
``active_profile`` for partial updates.
Accepts ``agent_settings_diff``, ``conversation_settings_diff``,
``active_profile``, and ``active_agent_profile_id`` for partial updates.

``agent_settings_diff`` is applied via :func:`apply_agent_settings_diff`:
RFC 7386 merge-patch semantics with kind-switch awareness. When
Expand Down Expand Up @@ -245,9 +254,11 @@ def update(self, payload: SettingsUpdatePayload) -> None:
if new_misc is not None:
self.misc_settings = new_misc

# Update active_profile if explicitly provided (including None to clear)
# Update pointers if explicitly provided (including None to clear)
if "active_profile" in payload:
self.active_profile = payload["active_profile"]
if "active_agent_profile_id" in payload:
self.active_agent_profile_id = payload["active_agent_profile_id"]
finally:
# Clear conv_merged to minimize plaintext exposure window
if conv_merged is not None:
Expand Down
26 changes: 21 additions & 5 deletions openhands-agent-server/openhands/agent_server/profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
ProfileLimitExceeded,
)
from openhands.sdk.logger import get_logger
from openhands.sdk.profiles import (
AgentProfileStore,
ProfileReferenced,
delete_llm_profile,
rename_llm_profile,
)


logger = get_logger(__name__)
Expand Down Expand Up @@ -227,10 +233,18 @@ async def save_profile(
async def delete_profile(
request: Request, name: ProfileName
) -> ProfileMutationResponse:
"""Delete a saved profile (idempotent)."""
"""Delete a saved profile (idempotent).

Guarded by the agent-profile FK: returns 409 naming the referrers if any
``AgentProfile`` still cites this LLM profile via ``llm_profile_ref``.
"""
store = LLMProfileStore()
with _store_errors():
store.delete(name)
agent_store = AgentProfileStore()
try:
with _store_errors():
delete_llm_profile(agent_store, store, name)
except ProfileReferenced as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
if _set_active_profile_if_matches(request, name, None):
logger.info(f"Cleared active_profile for deleted profile '{name}'")
logger.info(f"Deleted profile '{name}'")
Expand All @@ -249,12 +263,14 @@ async def rename_profile(
exists. A same-name rename is a verified no-op (still 404s if missing).

If the renamed profile is the currently active profile, the active_profile
setting is updated to the new name.
setting is updated to the new name. Any ``AgentProfile.llm_profile_ref``
citing the old name is cascaded to the new name in lock-step.
"""
store = LLMProfileStore()
agent_store = AgentProfileStore()
try:
with _store_errors():
store.rename(name, body.new_name)
rename_llm_profile(agent_store, store, name, body.new_name)
except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ async def get_settings(request: Request) -> SettingsResponse:
),
llm_api_key_is_set=settings.llm_api_key_is_set,
active_profile=settings.active_profile,
active_agent_profile_id=settings.active_agent_profile_id,
misc_settings=settings.misc_settings,
)

Expand Down Expand Up @@ -204,16 +205,20 @@ async def update_settings(
store = get_settings_store(config)

update_data = payload.model_dump(exclude_none=True)
# exclude_none drops an explicit null, so re-add nullable pointers when the
# client set them (including to None) to allow clearing.
if "active_profile" in payload.model_fields_set:
update_data["active_profile"] = payload.active_profile
if "active_agent_profile_id" in payload.model_fields_set:
update_data["active_agent_profile_id"] = payload.active_agent_profile_id
if not update_data:
# No updates provided - this is a client error
raise HTTPException(
status_code=400,
detail=(
"At least one of agent_settings_diff, "
"conversation_settings_diff, misc_settings_diff, "
"or active_profile must be provided"
"active_profile, or active_agent_profile_id must be provided"
),
)

Expand Down Expand Up @@ -269,6 +274,7 @@ def apply_update(settings: PersistedSettings) -> PersistedSettings:
conversation_settings=settings.conversation_settings.model_dump(mode="json"),
llm_api_key_is_set=settings.llm_api_key_is_set,
active_profile=settings.active_profile,
active_agent_profile_id=settings.active_agent_profile_id,
misc_settings=settings.misc_settings,
)

Expand Down
17 changes: 17 additions & 0 deletions openhands-sdk/openhands/sdk/settings/api_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
from openhands.sdk.llm.llm_profile_store import PROFILE_NAME_PATTERN


# An AgentProfile's stable id is a UUID (the pointer target); reject malformed
# values at the HTTP layer, mirroring ``active_profile``'s name pattern.
UUID_PATTERN = (
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)


if TYPE_CHECKING:
from .model import AgentSettingsConfig, ConversationSettings

Expand Down Expand Up @@ -69,6 +77,10 @@ class SettingsResponse(BaseModel):
default=None,
description="Name of the currently active LLM profile, if one is selected.",
)
active_agent_profile_id: str | None = Field(
default=None,
description="Stable id of the currently active AgentProfile, if one is set.",
)
misc_settings: dict[str, Any] = Field(default_factory=dict)

def get_agent_settings(self) -> AgentSettingsConfig:
Expand Down Expand Up @@ -113,6 +125,11 @@ class SettingsUpdateRequest(BaseModel):
pattern=PROFILE_NAME_PATTERN,
description="Name of the active LLM profile to persist; null clears it.",
)
active_agent_profile_id: str | None = Field(
Comment thread
simonrosenberg marked this conversation as resolved.
default=None,
pattern=UUID_PATTERN,
description="Stable id of the active AgentProfile to persist; null clears it.",
)


# ── Secrets API Models ────────────────────────────────────────────────────
Expand Down
Loading
Loading