fix(profiles): sync seeded default AgentProfile llm_profile_ref on LLM-profile activation - #4372
Open
AzeelSajjad wants to merge 4 commits into
Open
fix(profiles): sync seeded default AgentProfile llm_profile_ref on LLM-profile activation#4372AzeelSajjad wants to merge 4 commits into
AzeelSajjad wants to merge 4 commits into
Conversation
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
force-pushed
the
fix/4338-sync-seed-llm-profile-ref
branch
from
August 5, 2026 02:08
35c0627 to
4f06dd3
Compare
AzeelSajjad
marked this pull request as ready for review
August 5, 2026 02:10
Collaborator
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
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. |
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 This is an automated check - no AI was used to generate this comment. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HUMAN:
The
defaultagent profile kept pointing at anLLM 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}/activateupdatesagent_settings.llmandactive_profileatomically.POST /api/agent-profiles/{id}/activateis pointer-only by contract and must never writeagent_settings(ActivateAgentProfileResponse.agent_settings_appliedis hardcodedFalsefor exactly that reason). Nothing reconciled the two, so activating an LLM profile never updated anAgentProfile.llm_profile_refpointing at the previously-active profile.resolve_agent_profile()is the only path that re-resolvesllm_profile_refinto a real LLM config, and it runs only when a conversation is launched by profile id. The common path —GET /api/settings→ forwardagent_settingstoPOST /api/conversations— never touches it, so the seededdefaultAgentProfile 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
AgentProfilewhose 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 namedSEED_PROFILE_NAME, and only when it was demonstrably in sync beforehand. The seededdefaultis 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_profileholds the agent-store lock across scan-then-delete and raisesProfileReferenced. Verified againstorigin/mainwith a live server: the delete returns409 {"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, onGET /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 joiningfind_referrers/cascade_rename/delete_llm_profile/rename_llm_profile. Repoints the seeded profile'sllm_profile_refonly 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 surgicalset_llm_profile_ref, so the stableidandrevisionare untouched and no cipher is involved.llm_profile_ref_matches_active— an additive, read-only tri-state (bool | None) flag onGET /api/agent-profilesandGET /api/agent-profiles/{name}.Nonemeans "not applicable/unknown" (ACP profile, or nothing active) and never means "stale". Defaults toNone, so existing clients are unaffected.profiles_router.py) — calls the helper after the settings write, best-effort, plus an additivellm_profile_ref_synced: boolon the activation response.Issue Number
#4338
How to Test
Reproduced end-to-end against a live agent-server on both
origin/mainand this branch, using isolated state (OH_PERSISTENCE_DIR=$(mktemp -d), so~/.openhandsis untouched) and separate virtualenvs per tree.BEFORE —
origin/main@973c3513The
defaultAgentProfile namesprofile-awhile the system's active profile isprofile-b— two different named profiles, two different underlying models, no error anywhere. Exactly the drift reported in the issue.AFTER — this branch @
35c0627a(rebased to4f06dd3c; identical tree, no conflicts)Anti-clobber, same live run (both trees)
A separate
pinnedAgentProfile deliberately referencingprofile-c, thenprofile-bactivated again: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_nsso a redundant rewrite genuinely cannot slip through;id/revisionpreserved 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_serverin a single process shows 18 pre-existing failures (OpenAPI contract/discriminator tests) that are unrelated to this change —origin/main@973c3513fails 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 whentests/agent_serverruns alone.Video/Screenshots
Terminal transcripts from the live before/after server runs are inlined under How to Test above.
Type
Notes
Known gap, documented deliberately. The seeded profile can be born in three states; this repairs two:
default.llm_profile_ref"default", dangling"default", resolvesIn state C,
_seed_default_llm_profilemints an LLM profile nameddefaultmirroring live settings, so the ref resolves — and a resolving ref is indistinguishable from a deliberate user pin.revisionis not a usable discriminator:save_profile_preserving_identitybumps it only on overwrite, not on create, and the field defaults to0, so a user-authoreddefaulton 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_activereportsFalsefor it. This is recorded insync_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_profilenames 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_profilealready 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 orderrename_llm_profile/delete_llm_profiledepend on. Both constraints are commented at the call site.Best-effort by design. The sync is wrapped in a deliberately broad
except Exceptionwithexc_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)becauseprofile_refsis 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_profilescallssettings_store.load()unguarded, the sameOSErrorexposure this PR closes inget_agent_profile. Pre-existing; left alone to keep the diff tight.