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 @@ -39,6 +39,7 @@
SEED_PROFILE_NAME,
AgentProfileDiagnostics,
AgentProfileStore,
OpenHandsAgentProfile,
ProfileLimitExceeded,
build_seed_profile,
resolve_agent_profile_dry_run,
Expand Down Expand Up @@ -72,6 +73,7 @@ class AgentProfileInfo(BaseModel):
revision: int | None = None
llm_profile_ref: str | None = None
mcp_server_refs: list[str] | None = None
llm_profile_ref_matches_active: bool | None = None


class AgentProfileListResponse(BaseModel):
Expand All @@ -82,6 +84,7 @@ class AgentProfileListResponse(BaseModel):
class AgentProfileDetailResponse(BaseModel):
name: str
profile: dict[str, Any]
llm_profile_ref_matches_active: bool | None = None


class AgentProfileMutationResponse(BaseModel):
Expand Down Expand Up @@ -242,6 +245,36 @@ def set_pointer(s: PersistedSettings) -> PersistedSettings:
logger.info(f"Seeded default agent profile '{profile.name}' (id={profile_id})")


def _llm_profile_ref_matches_active(
agent_kind: str,
llm_profile_ref: str | None,
active_profile: str | None,
) -> bool | None:
"""Detect drift between a profile's ``llm_profile_ref`` and the active LLM
profile (#4338).

Activating an LLM profile (``POST /api/profiles/{name}/activate``) only
ever updates ``settings.active_profile`` — it never touches any stored
``AgentProfile.llm_profile_ref`` — so the two can silently point at
different LLM profiles with no error surfaced anywhere. This is a
read-only diagnostic: it never repairs the drift, only reports it.

Tri-state, not boolean: ``None`` means "not applicable / unknown" and
must never be read as "stale" —
- an ACP profile carries no ``llm_profile_ref`` at all, so comparison is
meaningless (``agent_kind != "openhands"``);
- no LLM profile is currently active (``active_profile is None``), so
there is nothing to compare against.
Only an explicit ``False`` means the ref has drifted from the active LLM
profile; only ``True`` means it is current.
"""
if agent_kind != "openhands":
return None
if active_profile is None:
return None
return llm_profile_ref == active_profile


def _summary_id_for_name(store: AgentProfileStore, name: str) -> str | None:
"""Return the stable id of the profile stored under ``name``, if present."""
with store_errors():
Expand Down Expand Up @@ -275,14 +308,30 @@ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
with store_errors():
summaries = store.list_summaries()

# AgentProfileInfo(**s) can't pick up llm_profile_ref_matches_active on its
# own: `s` is a raw store summary dict (`{id, name, agent_kind, revision,
# llm_profile_ref, mcp_server_refs}`) with no such key, so the computed
# value must be injected explicitly rather than relying on the splat.
return AgentProfileListResponse(
profiles=[AgentProfileInfo(**s) for s in summaries],
profiles=[
AgentProfileInfo(
**s,
llm_profile_ref_matches_active=_llm_profile_ref_matches_active(
s.get("agent_kind", "openhands"),
s.get("llm_profile_ref"),
settings.active_profile,
),
)
for s in summaries
],
active_agent_profile_id=settings.active_agent_profile_id,
)


@agent_profiles_router.get("/{name}", response_model=AgentProfileDetailResponse)
async def get_agent_profile(name: ProfileName) -> AgentProfileDetailResponse:
async def get_agent_profile(
request: Request, name: ProfileName
) -> AgentProfileDetailResponse:
"""Get a stored profile.

A profile is secret-free at rest (#4017), so there is nothing to mask or
Expand All @@ -299,8 +348,32 @@ async def get_agent_profile(name: ProfileName) -> AgentProfileDetailResponse:
detail=f"Agent profile '{name}' not found",
)

settings_store = get_settings_store(get_config(request))
# The staleness flag below is an optional diagnostic (#4338) riding along
# on the primary read — it must never turn a successful profile load into
# a 500. FileSettingsStore.load() deliberately re-raises PermissionError/
# OSError for a genuinely unreadable settings file; degrade to
# active_profile=None (the flag's own "unknown" state) instead of
# propagating.
try:
settings = settings_store.load() or PersistedSettings()
active_profile = settings.active_profile
except OSError:
active_profile = None

payload = profile.model_dump(mode="json")
return AgentProfileDetailResponse(name=name, profile=payload)
llm_profile_ref = (
profile.llm_profile_ref if isinstance(profile, OpenHandsAgentProfile) else None
)
return AgentProfileDetailResponse(
name=name,
profile=payload,
llm_profile_ref_matches_active=_llm_profile_ref_matches_active(
profile.agent_kind,
llm_profile_ref,
active_profile,
),
)


@agent_profiles_router.post(
Expand Down
44 changes: 44 additions & 0 deletions openhands-agent-server/openhands/agent_server/profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ProfileReferenced,
delete_llm_profile,
rename_llm_profile,
sync_seed_llm_ref,
)


Expand Down Expand Up @@ -283,6 +284,7 @@ class ActivateProfileResponse(BaseModel):
name: str
message: str
llm_applied: bool = True
llm_profile_ref_synced: bool = False


@profiles_router.post("/{name}/activate", response_model=ActivateProfileResponse)
Expand All @@ -295,6 +297,10 @@ async def activate_profile(
1. Loads the named profile's LLM configuration
2. Applies it to the current agent settings (updates ``agent_settings.llm``)
3. Records the profile name as the active profile for frontend tracking
4. Best-effort: repoints the seeded default AgentProfile's
``llm_profile_ref`` to track this activation, if still eligible
(#4338) — reported via ``llm_profile_ref_synced``; this step never
fails the activation itself

Returns 404 if the profile does not exist.

Expand All @@ -317,8 +323,13 @@ async def activate_profile(

# Apply the LLM config to settings and record active profile
settings_store = get_settings_store(config)
previous_active_profile: str | None = None

def apply_profile(settings: PersistedSettings) -> PersistedSettings:
# Captured under the settings-store lock, so this is an exact
# pre-update read (not a separate load() -> TOCTOU race).
nonlocal previous_active_profile
previous_active_profile = settings.active_profile
settings.agent_settings = settings.agent_settings.model_copy(
update={"llm": llm}
)
Expand All @@ -338,8 +349,41 @@ def apply_profile(settings: PersistedSettings) -> PersistedSettings:
)

logger.info(f"Activated profile '{name}'")

# Repoint the seeded default AgentProfile's llm_profile_ref to track this
# activation, if it is still eligible (#4338). This runs *after*
# settings_store.update() has returned and released the settings-store
# lock: _seed_default_profile already nests agent-profile-lock ->
# settings-lock, so acquiring the agent-profile lock while the settings
# lock is still held here would invert that order and deadlock.
# profile_store.list_summaries() below acquires and releases the
# LLM-profile lock entirely before sync_seed_llm_ref acquires the
# agent-profile lock, so this call site as a whole still resolves
# agent-before-llm — the same order rename_llm_profile and
# delete_llm_profile depend on. Keep it that way if this ever gets
# inlined/reordered. It is also best-effort — activation has already
# succeeded and been persisted by this point, so nothing from this
# diagnostic side effect may escape and turn a successful activation
# into a failed request.
llm_profile_ref_synced = False
try:
known_llm_profiles = {s["name"] for s in profile_store.list_summaries()}
llm_profile_ref_synced = sync_seed_llm_ref(
get_agent_profile_store(),
old_ref=previous_active_profile,
new_ref=name,
known_llm_profiles=known_llm_profiles,
)
except Exception as e:
logger.warning(
f"Failed to sync seed profile llm_profile_ref after activating "
f"'{name}': {e}",
exc_info=True,
)

return ActivateProfileResponse(
name=name,
message=f"Profile '{name}' activated and applied to current settings",
llm_applied=True,
llm_profile_ref_synced=llm_profile_ref_synced,
)
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
delete_llm_profile,
find_referrers,
rename_llm_profile,
sync_seed_llm_ref,
)
from openhands.sdk.profiles.resolver import (
AgentProfileDiagnostics,
Expand Down Expand Up @@ -64,5 +65,6 @@
"resolve_agent_profile_dry_run",
"safe_validation_error_detail",
"save_profile_preserving_identity",
"sync_seed_llm_ref",
"validate_agent_profile",
]
106 changes: 105 additions & 1 deletion openhands-sdk/openhands/sdk/profiles/profile_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

An ``OpenHandsAgentProfile.llm_profile_ref`` is a soft FK onto an LLM-profile
store key. ``find_referrers`` / ``cascade_rename`` / ``delete_llm_profile`` /
``rename_llm_profile`` keep that FK from dangling.
``rename_llm_profile`` / ``sync_seed_llm_ref`` keep that FK from dangling.

Store-agnostic: these touch the agent-profile store only through
:class:`~openhands.sdk.profiles.agent_profile_store.AgentProfileStoreProtocol`,
Expand All @@ -20,9 +20,12 @@

from openhands.sdk.logger import get_logger
from openhands.sdk.profiles.agent_profile_store import PROFILE_NAME_REGEX
from openhands.sdk.profiles.seed import SEED_PROFILE_NAME


if TYPE_CHECKING:
from collections.abc import Collection

from openhands.sdk.llm.llm_profile_store import LLMProfileMutator
from openhands.sdk.profiles.agent_profile_store import AgentProfileStoreProtocol

Expand Down Expand Up @@ -147,3 +150,104 @@ def rename_llm_profile(
with agent_store.lock():
llm_store.rename(old_name, new_name)
return _rewrite_refs(agent_store, old_name, new_name)


def sync_seed_llm_ref(
store: AgentProfileStoreProtocol,
*,
old_ref: str | None,
new_ref: str,
known_llm_profiles: Collection[str] | None = None,
profile_name: str = SEED_PROFILE_NAME,
) -> bool:
"""Repoint the seed profile's ``llm_profile_ref``, but only while in sync.

Activating an LLM profile (``POST /profiles/{name}/activate``) is supposed
to keep the seeded default profile's ref pointed at the active LLM profile
— but a naive unconditional write would clobber a ref the user has since
pinned to something else on purpose, silently discarding their intent
(#4338). So this is deliberately narrower than ``cascade_rename``: it
considers exactly one profile (``profile_name``, normally
:data:`SEED_PROFILE_NAME`) and only rewrites it when the current ref is
still *explainable* as "not yet repointed" rather than "chosen":

* ``current == old_ref`` — the seed was tracking the previous activation,
so following the new one preserves that tracking behavior, or
* ``current == SEED_PROFILE_NAME`` and ``known_llm_profiles`` is given and
does not contain ``SEED_PROFILE_NAME`` — the dangling soft-ref a freshly
seeded profile is born with before any LLM profile named ``"default"``
necessarily exists (#3933), not a real pin.

Any other current ref is a deliberate pin and is left alone. Note that with
``old_ref=None`` only the second branch can match — a stored ref is always
a string, never ``None``, so the first comparison can't accidentally
succeed.

Known gap — state C is never repaired: the seeded profile is born in one
of three states. (A) an LLM profile was already active, so the ref names
it — repaired by the first branch above. (B) no active profile and no
real LLM config, so the ref is ``SEED_PROFILE_NAME`` and dangles —
repaired by the soft-ref branch above. (C) no active profile but a
*real* LLM config, so ``_seed_default_llm_profile`` mints an actual LLM
profile named ``SEED_PROFILE_NAME`` mirroring it and the ref resolves.
State C is indistinguishable from a user having deliberately pinned a
profile they authored and named ``"default"`` themselves — there is no
sound discriminator between the two. In particular ``revision`` does not
work: ``save_profile_preserving_identity`` bumps it only on overwrite,
never on create, and it defaults to ``0``, so a user's own first save of
a ``"default"`` profile is ``revision == 0``, identical to the seed.
Treating state C as eligible would risk exactly the clobber this
function exists to prevent, so it stays unrepaired; the resulting drift
is not invisible — ``llm_profile_ref_matches_active`` in
``agent_profiles_router.py`` reports it as ``False``, which is what that
flag is for.

Concurrency note: two overlapping activations can interleave their
read-check-write windows and settle with the seed ref naming one
activated profile while ``active_profile`` names another — e.g. the
later activation's sync runs first, finds itself ineligible, and
declines, then the earlier activation's sync runs after and writes. The
outcome is a stale ref, the same condition the staleness flag surfaces;
it is not corruption, since each write is still atomic under the store
lock.

``known_llm_profiles`` is a plain collection of names rather than an LLM
store/mutator handle: ``LLMProfileMutator`` exposes only
``delete``/``rename`` and cannot answer an existence query, so accepting a
store here would force every backend (including cloud adapters) to grow a
lookup it may not otherwise need. Callers pass whatever listing they
already have to hand.

Holds :meth:`~AgentProfileStoreProtocol.lock` for the whole
read-check-write, closing the same TOCTOU window as ``cascade_rename``.
Returns ``True`` iff a write happened; ``current == new_ref`` returns
``False`` without writing, so an already-synced seed produces no revision
churn and no mtime change.
"""
_validate_name(new_ref)
with store.lock():
summary = next(
(s for s in store.list_summaries() if s.get("name") == profile_name),
None,
)
if summary is None:
return False
if summary.get("agent_kind", "openhands") != "openhands":
return False

current = summary.get("llm_profile_ref")
eligible = current == old_ref or (
current == SEED_PROFILE_NAME
and known_llm_profiles is not None
and SEED_PROFILE_NAME not in known_llm_profiles
)
if not eligible or current == new_ref:
return False

store.set_llm_profile_ref(profile_name, new_ref)

logger.info(
f"[Profile FK] Synced seed profile `{profile_name}` llm_profile_ref "
f"`{current}` -> `{new_ref}`."
)
return True
Loading
Loading