You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
POST /api/profiles/{name}/activate (the LLM-profile activation endpoint) updates agent_settings.llm and active_profile atomically. POST /api/agent-profiles/{id}/activate (the AgentProfile activation endpoint) is, by
contract, pointer-only and must never touch agent_settings
(ActivateAgentProfileResponse.agent_settings_applied is hardcoded False for
exactly this reason). Nothing reconciles the two: activating an LLM profile never
updates any AgentProfile.llm_profile_ref that pointed at the previously-active
profile, so a seeded/well-known default AgentProfile can end up permanently
referencing a stale or deleted LLM profile while the system's actual active LLM
profile has moved on.
# openhands-agent-server/openhands/agent_server/agent_profiles_router.py"""...Activation here is pointer-only — unlike the LLM ``/activate`` it must**not** write ``agent_settings`` (the creation-time-only contract)."""
...
classActivateAgentProfileResponse(BaseModel):
...
agent_settings_applied: bool=False# Always False: activation is pointer-only by contract.
resolve_agent_profile() (the only code path that actually re-resolves llm_profile_ref into a real LLM config) is invoked from conversation_service.py's _resolve_agent_from_profile, which only runs when a
conversation is launched by profile id. The much more common path — a client
fetching GET /api/settings and forwarding agent_settings to POST /api/conversations (the pattern used by this repo's own agent-server automations,
and by the local/well-known default AgentProfile launch path per #16193 in
OpenHands/OpenHands) — never touches resolve_agent_profile and therefore never
notices that llm_profile_ref is stale.
Steps to Reproduce
Save two LLM profiles, e.g. POST /api/profiles/profile-a and POST /api/profiles/profile-b.
POST /api/profiles/profile-a/activate — active_profile is now profile-a.
Confirm (or create) an AgentProfile named default whose llm_profile_ref
is profile-a (this is the seeded default in a fresh instance).
POST /api/profiles/profile-b/activate — active_profile is now profile-b, agent_settings.llm now reflects profile-b.
GET /api/agent-profiles/default — llm_profile_ref still reads profile-a.
DELETE /api/profiles/profile-a — succeeds (no FK violation, since nothing
currently guards default's specific well-known status the way named profiles
are guarded elsewhere).
GET /api/agent-profiles/default — llm_profile_ref now names a profile
that no longer exists, with no error, warning, or repair anywhere in the
response.
Actual Impact Observed
Reproduced this drift on a live deployment: the active LLM profile and the default AgentProfile's llm_profile_ref had silently diverged (two different
named profiles, two different underlying models), with no error surfaced anywhere
in GET /api/settings or GET /api/agent-profiles. This is the same root cause
independently reported (and only partially fixed — frontend gate logic only, per
the shipped PR) in OpenHands/OpenHands#16193, whose own "Suggested fix" section
explicitly flagged this as an unaddressed follow-up:
Keeping the seeded default.llm_profile_ref synchronized when the active LLM
profile changes may also reduce stale state, but the frontend gate should still
agree with the launch semantics.
See also #3841 (LLM/AgentProfile identity asymmetry — related but distinct: that
issue is about rename-safety, this one is about activation-time sync) and #4314
(a different, now-fixed desync path between PATCH /api/settings and agent_settings.llm — same category of bug, different trigger).
Possible Fix Directions
Not prescribing one — this is a real design decision:
(a) When an LLM profile is deactivated (a different one is activated) or
deleted, cascade-update any AgentProfile.llm_profile_ref that pointed at it —
at minimum for the seeded default profile, which is already special-cased
elsewhere (see [Bug]: Composer blocks local default AgentProfile despite a valid active LLM profile OpenHands#16193/#16200) as "the enriched baseline that
launches through live agent_settings."
(b) Expose a computed staleness flag on GET /api/agent-profiles/{id} (e.g. llm_profile_ref_matches_active: bool) so callers can detect drift without
reimplementing the diff themselves.
(c) Auto-/materialize (or an equivalent dry-run resolve) as part of the GET /api/agent-profiles/{id} response so staleness is visible without a
separate call.
(d) Guard DELETE /api/profiles/{name} for default's current llm_profile_ref the same way it already guards other referenced profiles
(the FK check in delete_llm_profile exists — worth checking why it didn't
catch this).
Bug Description
POST /api/profiles/{name}/activate(the LLM-profile activation endpoint) updatesagent_settings.llmandactive_profileatomically.POST /api/agent-profiles/{id}/activate(the AgentProfile activation endpoint) is, bycontract, pointer-only and must never touch
agent_settings(
ActivateAgentProfileResponse.agent_settings_appliedis hardcodedFalseforexactly this reason). Nothing reconciles the two: activating an LLM profile never
updates any
AgentProfile.llm_profile_refthat pointed at the previously-activeprofile, so a seeded/well-known
defaultAgentProfile can end up permanentlyreferencing a stale or deleted LLM profile while the system's actual active LLM
profile has moved on.
Root Cause
versus
resolve_agent_profile()(the only code path that actually re-resolvesllm_profile_refinto a real LLM config) is invoked fromconversation_service.py's_resolve_agent_from_profile, which only runs when aconversation is launched by profile id. The much more common path — a client
fetching
GET /api/settingsand forwardingagent_settingstoPOST /api/conversations(the pattern used by this repo's own agent-server automations,and by the local/well-known
defaultAgentProfile launch path per #16193 inOpenHands/OpenHands) — never touches
resolve_agent_profileand therefore nevernotices that
llm_profile_refis stale.Steps to Reproduce
POST /api/profiles/profile-aandPOST /api/profiles/profile-b.POST /api/profiles/profile-a/activate—active_profileis nowprofile-a.AgentProfilenameddefaultwhosellm_profile_refis
profile-a(this is the seeded default in a fresh instance).POST /api/profiles/profile-b/activate—active_profileis nowprofile-b,agent_settings.llmnow reflectsprofile-b.GET /api/agent-profiles/default—llm_profile_refstill readsprofile-a.DELETE /api/profiles/profile-a— succeeds (no FK violation, since nothingcurrently guards
default's specific well-known status the way named profilesare guarded elsewhere).
GET /api/agent-profiles/default—llm_profile_refnow names a profilethat no longer exists, with no error, warning, or repair anywhere in the
response.
Actual Impact Observed
Reproduced this drift on a live deployment: the active LLM profile and the
defaultAgentProfile'sllm_profile_refhad silently diverged (two differentnamed profiles, two different underlying models), with no error surfaced anywhere
in
GET /api/settingsorGET /api/agent-profiles. This is the same root causeindependently reported (and only partially fixed — frontend gate logic only, per
the shipped PR) in OpenHands/OpenHands#16193, whose own "Suggested fix" section
explicitly flagged this as an unaddressed follow-up:
See also #3841 (LLM/AgentProfile identity asymmetry — related but distinct: that
issue is about rename-safety, this one is about activation-time sync) and #4314
(a different, now-fixed desync path between
PATCH /api/settingsandagent_settings.llm— same category of bug, different trigger).Possible Fix Directions
Not prescribing one — this is a real design decision:
deleted, cascade-update any
AgentProfile.llm_profile_refthat pointed at it —at minimum for the seeded
defaultprofile, which is already special-casedelsewhere (see [Bug]: Composer blocks local default AgentProfile despite a valid active LLM profile OpenHands#16193/#16200) as "the enriched baseline that
launches through live
agent_settings."GET /api/agent-profiles/{id}(e.g.llm_profile_ref_matches_active: bool) so callers can detect drift withoutreimplementing the diff themselves.
/materialize(or an equivalent dry-run resolve) as part of theGET /api/agent-profiles/{id}response so staleness is visible without aseparate call.
DELETE /api/profiles/{name}fordefault's currentllm_profile_refthe same way it already guards other referenced profiles(the FK check in
delete_llm_profileexists — worth checking why it didn'tcatch this).