Skip to content

fix(profiles): sync seeded default AgentProfile llm_profile_ref on LLM-profile activation - #4372

Open
AzeelSajjad wants to merge 4 commits into
OpenHands:mainfrom
AzeelSajjad:fix/4338-sync-seed-llm-profile-ref
Open

fix(profiles): sync seeded default AgentProfile llm_profile_ref on LLM-profile activation#4372
AzeelSajjad wants to merge 4 commits into
OpenHands:mainfrom
AzeelSajjad:fix/4338-sync-seed-llm-profile-ref

Conversation

@AzeelSajjad

@AzeelSajjad AzeelSajjad commented Aug 5, 2026

Copy link
Copy Markdown

HUMAN:

The default agent profile kept pointing at an
LLM profile I'd switched away from, and nothing surfaced it. The fix keeps the
seeded profile following the active LLM profile, but only when it was already
in sync, so a profile you've deliberately pinned doesn't get rewritten out from
under you.


AGENT:

Why

Fixes #4338.

POST /api/profiles/{name}/activate updates agent_settings.llm and active_profile atomically. POST /api/agent-profiles/{id}/activate is pointer-only by contract and must never write agent_settings (ActivateAgentProfileResponse.agent_settings_applied is hardcoded False for exactly that reason). Nothing reconciled the two, so activating an LLM profile never updated an AgentProfile.llm_profile_ref pointing at the previously-active profile.

resolve_agent_profile() is the only path that re-resolves llm_profile_ref into a real LLM config, and it runs only when a conversation is launched by profile id. The common path — GET /api/settings → forward agent_settings to POST /api/conversations — never touches it, so the seeded default AgentProfile could sit on a stale reference indefinitely with no error, warning, or repair surfaced anywhere.

Why the cascade is narrow rather than global. The obvious fix — repoint every AgentProfile whose ref matched the outgoing profile — silently rewrites a profile a user deliberately pinned that merely happened to coincide with the active one. So this repoints only the profile named SEED_PROFILE_NAME, and only when it was demonstrably in sync beforehand. The seeded default is a mirror of live settings, not a user pin; everything else is left byte-identical. Drift that the cascade deliberately declines to repair is instead made observable via a new read-only flag, so callers can detect it without reimplementing the diff.

On issue direction (d). The issue asked whether DELETE /api/profiles/{name} was guarded and "why it didn't catch this." It is guarded — delete_llm_profile holds the agent-store lock across scan-then-delete and raises ProfileReferenced. Verified against origin/main with a live server: the delete returns 409 {"detail":"LLM profile is referenced by 1 agent profile(s): default"}. The issue's repro step 6 only succeeds when the agent-profile store was never seeded (seeding is lazy, on GET /api/agent-profiles). No code change was needed; a regression test now pins the guard.

Summary

  • sync_seed_llm_ref (openhands-sdk/.../profiles/profile_refs.py) — a store-agnostic FK helper joining find_referrers / cascade_rename / delete_llm_profile / rename_llm_profile. Repoints the seeded profile's llm_profile_ref only when it currently equals the outgoing profile, or is the dangling soft-ref a fresh instance is born with (Seeded default AgentProfile has dangling llm_profile_ref ("default") → conversation launch 404s #3933). Holds the agent-store lock across the whole read-check-write and writes through the existing surgical set_llm_profile_ref, so the stable id and revision are untouched and no cipher is involved.
  • llm_profile_ref_matches_active — an additive, read-only tri-state (bool | None) flag on GET /api/agent-profiles and GET /api/agent-profiles/{name}. None means "not applicable/unknown" (ACP profile, or nothing active) and never means "stale". Defaults to None, so existing clients are unaffected.
  • Activation hook (profiles_router.py) — calls the helper after the settings write, best-effort, plus an additive llm_profile_ref_synced: bool on the activation response.

Issue Number

#4338

How to Test

Reproduced end-to-end against a live agent-server on both origin/main and this branch, using isolated state (OH_PERSISTENCE_DIR=$(mktemp -d), so ~/.openhands is untouched) and separate virtualenvs per tree.

export OH_PERSISTENCE_DIR=$(mktemp -d)
uv run python -m openhands.agent_server --port 8751 &
API=http://127.0.0.1:8751/api

curl -sf -X POST $API/profiles/profile-a -H 'Content-Type: application/json' \
  -d '{"llm":{"model":"gpt-4o","api_key":"sk-aaa"},"include_secrets":true}'
curl -sf -X POST $API/profiles/profile-b -H 'Content-Type: application/json' \
  -d '{"llm":{"model":"claude-sonnet-4","api_key":"sk-bbb"},"include_secrets":true}'

curl -sf -X POST $API/profiles/profile-a/activate   # active_profile = profile-a
curl -sf $API/agent-profiles                        # lazily seeds `default` (ref = profile-a)
curl -sf -X POST $API/profiles/profile-b/activate   # <-- the trigger
curl -sf $API/agent-profiles/default                # observe llm_profile_ref
curl -s  -X DELETE $API/profiles/profile-a -w '%{http_code}\n'

BEFORE — origin/main @ 973c3513

== 4. Seeded default's llm_profile_ref BEFORE activating profile-b ==
  llm_profile_ref = profile-a
  llm_profile_ref_matches_active = <field absent>

== 5. Activate profile-b  <-- THE TRIGGER ==
{"name":"profile-b","message":"Profile 'profile-b' activated and applied to current settings","llm_applied":true}

== 6. Seeded default's llm_profile_ref AFTER activating profile-b ==
  llm_profile_ref = profile-a          <-- DRIFT: still the old profile
  llm_profile_ref_matches_active = <field absent>

== 7. active_profile per GET /api/settings ==
  active_profile = profile-b
  agent_settings.llm.model = claude-sonnet-4

== 8. DELETE the now-unreferenced profile-a ==
  HTTP 409
  {"detail":"LLM profile is referenced by 1 agent profile(s): default"}

The default AgentProfile names profile-a while the system's active profile is profile-b — two different named profiles, two different underlying models, no error anywhere. Exactly the drift reported in the issue.

AFTER — this branch @ 35c0627a (rebased to 4f06dd3c; identical tree, no conflicts)

== 4. Seeded default's llm_profile_ref BEFORE activating profile-b ==
  llm_profile_ref = profile-a
  llm_profile_ref_matches_active = True

== 5. Activate profile-b  <-- THE TRIGGER ==
{"name":"profile-b","message":"Profile 'profile-b' activated and applied to current settings","llm_applied":true,"llm_profile_ref_synced":true}

== 6. Seeded default's llm_profile_ref AFTER activating profile-b ==
  llm_profile_ref = profile-b          <-- repaired
  llm_profile_ref_matches_active = True

== 7. active_profile per GET /api/settings ==
  active_profile = profile-b
  agent_settings.llm.model = claude-sonnet-4

== 8. DELETE the now-unreferenced profile-a ==
  HTTP 200
  {"name":"profile-a","message":"Profile 'profile-a' deleted"}

Anti-clobber, same live run (both trees)

A separate pinned AgentProfile deliberately referencing profile-c, then profile-b activated again:

  pinned.llm_profile_ref = profile-c   <-- unchanged, as required
  llm_profile_ref_matches_active = False

The pinned profile does not move, and the new flag reports its divergence rather than silently repairing it.

Unit tests

19 new tests (9 SDK, 10 agent-server). Run:

uv run pytest tests/sdk/profiles tests/agent_server -q -p no:randomly
# 2030 passed, 13 deselected in 263.07s   (re-run after rebase onto 0c8f97aa)

Notable coverage: the anti-clobber guarantee (pinned profile untouched); the no-write case asserted via st_mtime_ns so a redundant rewrite genuinely cannot slip through; id/revision preserved across a repoint; activation still returning 200 when the agent-profile store raises; and the FK-guard regression pin for direction (d).

Note for reviewers on suite selection: tests/sdk + tests/agent_server in a single process shows 18 pre-existing failures (OpenAPI contract/discriminator tests) that are unrelated to this change — origin/main @ 973c3513 fails the same 18 with zero code changes (base 18 failed/7499 passed, this branch 18 failed/7518 passed, +19 being exactly the tests added here). Those files pass when tests/agent_server runs alone.

Video/Screenshots

Terminal transcripts from the live before/after server runs are inlined under How to Test above.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Known gap, documented deliberately. The seeded profile can be born in three states; this repairs two:

State At seed time default.llm_profile_ref Repaired
A an LLM profile was already active that profile's name yes
B no active profile, no real LLM config "default", dangling yes
C no active profile but a real LLM config "default", resolves no

In state C, _seed_default_llm_profile mints an LLM profile named default mirroring live settings, so the ref resolves — and a resolving ref is indistinguishable from a deliberate user pin. revision is not a usable discriminator: save_profile_preserving_identity bumps it only on overwrite, not on create, and the field defaults to 0, so a user-authored default on its first save looks identical to the seeded one. Repairing state C would therefore risk exactly the clobber this design prevents. It is not invisible — llm_profile_ref_matches_active reports False for it. This is recorded in sync_seed_llm_ref's docstring; happy to revisit if maintainers prefer a different trade-off.

Concurrency. Two overlapping activations can settle with the seed ref naming one profile while active_profile names another (if the later activation's sync runs first it declines, then the earlier one writes). The outcome is a stale ref surfaced by the flag, never corruption — each write is atomic under the store lock. Documented in the helper's docstring.

Lock ordering. The sync runs after settings_store.update() releases its lock, never inside the callback: _seed_default_profile already nests agent-lock → settings-lock, so the reverse nesting could deadlock. list_summaries() likewise acquires and releases the LLM-profile lock before the agent-profile lock is taken, preserving the agent-before-llm order rename_llm_profile / delete_llm_profile depend on. Both constraints are commented at the call site.

Best-effort by design. The sync is wrapped in a deliberately broad except Exception with exc_info=True. Activation has already succeeded and been persisted by that point; a failure in a side effect must not turn it into a 500. The catch is broad rather than (TimeoutError, ValueError, OSError) because profile_refs is store-agnostic by contract — a DB-backed store's driver exceptions land in none of those hierarchies.

Out of scope, noted for a follow-up: list_agent_profiles calls settings_store.load() unguarded, the same OSError exposure this PR closes in get_agent_profile. Pre-existing; left alone to keep the diff tight.

AzeelSajjad and others added 4 commits August 4, 2026 22:03
Adds a narrow, store-agnostic helper that repoints the seeded default
AgentProfile's llm_profile_ref alongside LLM-profile activation, without
clobbering a ref the user has since pinned to something else. Joins the
existing find_referrers/cascade_rename/delete_llm_profile/rename_llm_profile
family in profile_refs.py. Wiring into the activation endpoint is a later
task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… flag (OpenHands#4338)

Activating an LLM profile never updates a stored AgentProfile's
llm_profile_ref, so the two can silently drift with no error surfaced.
Add a read-only, tri-state llm_profile_ref_matches_active field to both
the list and detail agent-profile endpoints (True/False for an OpenHands
profile compared against the active LLM profile; None when there is no
active LLM profile or the profile is ACP, which carries no ref at all).
…tivation (OpenHands#4338)

Wires the already-landed sync_seed_llm_ref helper into POST
/api/profiles/{name}/activate so the seeded 'default' AgentProfile's
llm_profile_ref tracks the newly activated LLM profile, closing the
gap where activation updated agent_settings.llm/active_profile but
left the default agent profile's ref pointing at a stale (or deleted)
LLM profile.

The pre-update active_profile is captured under the settings-store
lock via a nonlocal in the existing apply_profile callback (no TOCTOU
load). The sync itself runs after settings_store.update() returns, to
avoid inverting the agent-profile-lock -> settings-lock order that
_seed_default_profile already establishes, and is best-effort: a
TimeoutError/ValueError/OSError is logged and swallowed so a
successful activation always returns 200.

Adds llm_profile_ref_synced: bool = False to ActivateProfileResponse
(additive) and five tests covering the end-to-end repro, a pinned-ref
no-op, best-effort failure handling, an empty store, and a regression
pin for the existing FK delete guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Document the state-C coverage gap and concurrent-activation
  interleaving in sync_seed_llm_ref's docstring; eligibility predicate
  is unchanged per owner ruling.
- Broaden activate_profile's best-effort sync catch to `except
  Exception` (with exc_info) so a DB-backed store's driver exceptions
  can't turn a successful activation into a 500; note the
  agent-before-llm lock ordering the call site preserves.
- Update activate_profile's docstring and profile_refs.py's module
  docstring to reflect the seed-ref sync step and
  llm_profile_ref_synced field.
- Guard get_agent_profile's settings_store.load() so an unreadable
  settings file degrades the diagnostic staleness flag to None instead
  of 500ing the profile read; narrow llm_profile_ref access with
  isinstance(OpenHandsAgentProfile) instead of getattr.
- Rework the seed-sync repro test to trigger the real lazy seed via GET
  /api/agent-profiles instead of hand-writing the AgentProfile, with an
  explicit precondition assertion.
@AzeelSajjad
AzeelSajjad force-pushed the fix/4338-sync-seed-llm-profile-ref branch from 35c0627 to 4f06dd3 Compare August 5, 2026 02:08
@AzeelSajjad
AzeelSajjad marked this pull request as ready for review August 5, 2026 02:10
@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Activating an LLM profile via /api/profiles/{name}/activate doesn't update AgentProfile.llm_profile_ref, allowing the two to drift silently

2 participants