Skip to content

fix(sdk): install the edited profile when switch_profile re-switches - #5852

Open
enyst wants to merge 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-switch-profile-edited
Open

enyst wants to merge 2 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-switch-profile-edited

Conversation

@enyst

@enyst enyst commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

HUMAN:
This PR proposes a fix for a bug uncovered during the first full agentic run of verify-agent-server.


AGENT:

Summary

switch_profile(name)
  └─ load current profile from disk every time
       ├─ registered config unchanged → reuse existing LLM
       └─ config changed
            ├─ materialize credentials first
            ├─ remove stale registry entry → switch_llm(edited profile)
            └─ restore previous Metrics object

usage key stays profile:<name>

Reload a selected profile from disk, replace changed LLM configuration, and preserve its accumulated metrics.

Why

Re-selecting an edited LLM profile should install its new configuration without losing accumulated usage.

Issue Number

Fixes #5839

How to Test

Previously recorded verification:

Reproduction commands, results, and limits
  • uv run pytest tests/sdk/conversation/test_switch_model.py -k edited: both new tests fail on main ('fast-model' == 'fast-model-v2') and pass here. uv run pytest tests/sdk/conversation tests/sdk/tool/test_switch_llm.py tests/agent_server/test_conversation_router.py: 979 passed.
  • Map proof, control-agent-server map run --file F09 --only F09.switch-profile-edited --fresh --repeat 2: xfail in both attempts with --checkout on origin/main (6e7a853), xpass in both from this branch. After dropping the marker, map check reports no problems (935 ids) and the same --fresh run reports pass.
  • uv run pre-commit run --files <changed files>: all hooks pass.

Video/Screenshots

No UI change; reproduction evidence is above.

Type

  • Bug fix

Notes

Behavior tradeoff: refresh on every switch (implemented) rather than retaining the documented conversation cache or adding a refresh flag. Subscription profiles re-materialize credentials on re-switch; materialization failure leaves the old registry/active LLM in place. Related switch work: #5779 and #5816.

Visual explanation by Codex using HumanLayer show-me.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:a503c19-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-a503c19-python \
  ghcr.io/openhands/agent-server:a503c19-python

All tags pushed for this build

ghcr.io/openhands/agent-server:a503c19-golang-amd64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-golang-amd64
ghcr.io/openhands/agent-server:a503c19-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:a503c19-golang-arm64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-golang-arm64
ghcr.io/openhands/agent-server:a503c19-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:a503c19-java-amd64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-java-amd64
ghcr.io/openhands/agent-server:a503c19-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:a503c19-java-arm64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-java-arm64
ghcr.io/openhands/agent-server:a503c19-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:a503c19-python-amd64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-amd64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-amd64
ghcr.io/openhands/agent-server:a503c19-python-arm64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-arm64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-arm64
ghcr.io/openhands/agent-server:a503c19-python-minimal-amd64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-minimal-amd64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:a503c19-python-minimal-arm64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-minimal-arm64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:a503c19-python-slim-amd64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-slim-amd64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:a503c19-python-slim-arm64
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-slim-arm64
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:a503c19-golang
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-golang
ghcr.io/openhands/agent-server:a503c19-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:a503c19-java
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-java
ghcr.io/openhands/agent-server:a503c19-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:a503c19-python-minimal
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-minimal
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-minimal
ghcr.io/openhands/agent-server:a503c19-python-slim
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python-slim
ghcr.io/openhands/agent-server:a503c19-python-node-runtime-slim
ghcr.io/openhands/agent-server:a503c19-python
ghcr.io/openhands/agent-server:a503c1964e6bff3dc9efaa629fee51eca4fa3633-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-switch-profile-edited-python
ghcr.io/openhands/agent-server:a503c19-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., a503c19-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., a503c19-python-amd64) are also available if needed

Jev-Fast-Audit

⚡ Jev fast audit · estimates · 0.32s · commit 412e8fc
Strongest signal: No primary concern selected.
Evidence: No primary concern to locate.
Coverage: complete supplied coverage; 7/7 hunks, 3/3 files.

All estimates and evidence
Estimate Likelihood / value Direct evidence
SQL injection 3.0% No direct hunk selected
Command injection 3.0% No direct hunk selected
Weakened authentication 6.0% No direct hunk selected
Weakened authorization 8.0% No direct hunk selected
Contract regression 28.0% F002H002 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1697–1707
Data loss 12.0% F002H003 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:1711–1730
Sensitive data disclosure 12.0% F002H001 · openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py:177–187
Unexpected data transfer 4.0% No direct hunk selected
Credential misuse 9.0% No direct hunk selected
Untrusted instruction authority 3.0% No direct hunk selected
Package source redirection 4.0% No direct hunk selected
Unverified remote execution 2.0% No direct hunk selected
Privileged environment access 7.0% No direct hunk selected
Security assessment bypass 5.0% No direct hunk selected
Prohibited workload 2.0% No direct hunk selected
Primary concern None selected; confidence 56.0% No primary concern to locate

LocalConversation.switch_profile looked up profile:<name> in the LLM
registry before reading the profile file, so a profile edited after the
conversation's first switch to it was never installed (the route still
answered 200). Read the profile on every call; reuse the registered LLM
while the config is unchanged and replace it, keeping its metrics, when
the profile was edited. Drop the F09.switch-profile-edited known-bug
marker.

Fixes #5839

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py11337194%168, 183, 372, 438, 710–711, 753, 795, 1155–1156, 1161, 1176, 1178, 1249, 1346, 1372, 1461, 1466–1471, 1524, 1544–1546, 1595, 1929–1930, 2193, 2196–2197, 2222, 2253, 2259, 2398, 2401, 2404, 2408–2409, 2413–2414, 2417, 2424, 2444, 2448, 2451, 2470, 2522, 2525, 2557, 2564–2565, 2573, 2577–2579, 2586, 2758, 2763, 2883–2884, 2902–2903, 2936, 3148, 3152, 3222, 3229–3230
TOTAL502571342173% 

@enyst
enyst marked this pull request as ready for review October 14, 2026 00:36
@enyst
enyst requested a review from all-hands-bot October 14, 2026 00:50

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Scope: this is SDK conversation behavior, which this repository owns, so the change belongs here. Current head 412e8fc35b005be78e5920bffdb26a11380600bb is unchanged from the requested SHA, PR is open and not a draft, and the current-head checks are green. Locally, tests/sdk/conversation/test_switch_model.py (27 passed) and tests/sdk/conversation + tests/sdk/tool/test_switch_llm.py + tests/agent_server/test_conversation_router.py (979 passed) both pass.

The core fix is correct: for a conversation whose agent was already initialized, re-switching to an edited profile replaces the registered profile:<name> LLM with the edited config, and an unchanged profile still reuses the registered object. I confirmed both behaviors directly, including repeated edits (edit → re-switch → edit → re-switch keeps the same metrics object and the stats entry stays in sync).

One material finding, on the new metrics-carry-over line:

switch_profile unconditionally calls installed.restore_metrics(stale.metrics) after the swap. That is only correct when stats.usage_to_metrics[usage_id] already references stale.metrics. When the first switch to the profile happened before the agent was initialized, the stale entry was registered before _ensure_agent_ready() wired ConversationStats.register_llm as the registry subscriber, so profile:<name> is not in stats.usage_to_metrics at all. On the edited re-switch, switch_llm → LLMRegistry.add → notify then records the new LLM's fresh metrics object into stats.usage_to_metrics[usage_id], and restore_metrics(stale.metrics) immediately swaps the live LLM onto the stale object. The result is that stats.usage_to_metrics["profile:<name>"] is left pointing at the now-orphaned fresh object: it never grows, get_combined_metrics() omits the calls made on the installed LLM, and the invariant asserted by the new test (stats.get_metrics_for_usage("profile:fast") is installed.metrics) is false.

Reachable sequence (agent initialized between the first switch and the re-switch — e.g. a conversation that switched to the profile, ran a turn, and then had the profile edited before switching again):

conv.switch_profile("p")   # before agent init
conv.send_message("hi")    # _ensure_agent_ready wires the subscriber; profile:p is skipped (already registered)
store.save("p", <edited llm>)
conv.switch_profile("p")
# conv.agent.llm.metrics is not conv.conversation_stats.usage_to_metrics["profile:p"]

Measured on the current head: after the edited re-switch, stats.usage_to_metrics["profile:p"] exists but is not conv.agent.llm.metrics; a subsequent call's cost is recorded on the live LLM and get_combined_metrics() still reports 0. (The combined-cost under-report in this pre-init path is pre-existing; what this diff adds is the stats entry that is silently disconnected from the installed LLM, plus the failure of the PR's own "keeps the usage recorded so far" contract and test invariant in that path.)

The new tests only cover the initialized-first path, which is why they pass. Note that restoring the metrics onto profile_llm before switch_llm is not a sufficient fix either, because LLMRegistry._ensure_independent_metrics resets a metrics object whose id it already tracks. The carry-over needs to end with stats.usage_to_metrics[usage_id] referencing the same object the installed LLM uses (for example by re-pointing the stats entry after the restore), not just the LLM.

Everything else in the diff (the docstring, the F09 map prose and Gotchas line) matches the implemented behavior.

🔄 CHANGES REQUESTED

self.llm_registry.remove(usage_id)
self.switch_llm(profile_llm)
installed = self.llm_registry.get(usage_id)
installed.restore_metrics(stale.metrics)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This carries the stale metrics onto the installed LLM only if stats.usage_to_metrics[usage_id] already references stale.metrics. When the profile's first switch happened before the agent was initialized, that entry does not exist: switch_llm -> LLMRegistry.add -> notify records the new LLM's fresh metrics into stats.usage_to_metrics[usage_id], and this line then swaps the live LLM onto stale.metrics, leaving the stats entry pointing at the now-orphaned fresh object. Subsequent usage accrues on the live LLM while get_combined_metrics() omits it, and the new test's invariant (stats.get_metrics_for_usage("profile:fast") is installed.metrics) is false in this path. Reachable when the conversation switched to the profile before its first send_message/run, then had the profile edited before switching again. Re-pointing the stats entry after the restore (so it references the same object the installed LLM uses) is needed; restoring onto profile_llm before switch_llm is not sufficient because LLMRegistry._ensure_independent_metrics resets a metrics object id it already tracks.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed after this PR merges to main through a cleanup PR.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was posted by an AI agent (OpenHands).

Scope and current state. This is SDK conversation behavior (LocalConversation.switch_profile/switch_llm, LLMProfileStore), which this repository owns, so the change stays here. Re-read immediately before publishing: PR #5852 is open, not a draft, head is exactly a503c1964e6bff3dc9efaa629fee51eca4fa3633 as requested, no labels, and linked issue #5839 is open with bug / priority:low / ready-for-dev. Checks on this exact head: 49 success, 2 skipped, and one failure — Review Thread Gate / unresolved-review-threads, which reflects the earlier bot review's unresolved thread on the previous head, not a code failure; this review supersedes that review. Locally on this head: tests/sdk/conversation/test_switch_model.py 31 passed; tests/sdk/conversation + tests/sdk/tool/test_switch_llm.py 900 passed; tests/agent_server/test_conversation_router.py 114 passed. The claimed verification reproduces.

Merge note. This branch merged upstream main (ce5716a), and the merge rewrote the fix: switch_profile now always loads the profile and delegates to switch_llm, while switch_llm (now from main) replaces a registered entry whose config differs and carries its metrics over. The F09 feature map and the docstring were updated to match, and main's own tests cover the replacement semantics under the default usage id. The scope and tests in the PR description still describe the delivered behavior accurately.

Material finding — metrics desync when the first switch precedes agent initialization. The carry-over in switch_llm rests on ConversationStats tracking the registered LLM's metrics, but a profile LLM registered before the agent is initialized never enters stats.usage_to_metrics (_ensure_agent_ready() wires the register_llm subscriber and then skips usage ids that are already registered). When the profile is edited and switch_profile is called again, the swap and restore_metrics still work, but ConversationStats never tracks the installed LLM's metrics. Measured on this head (switch pre-init → send_message → edit profile → re-switch):

  • conv.agent.llm.model == "fast-model-v2" — the fix itself works;
  • installed profile:fast LLM metrics.accumulated_cost == 1.5 (carried over) while stats.usage_to_metrics["profile:fast"].accumulated_cost == 0.0 and get_combined_metrics() == 0.0;
  • stats.usage_to_metrics["profile:fast"] is a different object than the installed LLM's metrics, so post-switch usage on the profile accrues on an object the combined view never reads, and the carried-over cost is silently dropped.

In this path the docstring's "keeps the usage recorded so far" (and the F09 map's "keeping the usage recorded under that id") is false, and the PR's own invariant — stats.get_metrics_for_usage("profile:fast") is installed.metrics — fails; the new tests only cover the initialized-first path, so they pass. I confirmed the same failure on the pre-merge head 412e8fc in a separate worktree, so the merge carried the finding over rather than introducing it — either way it is present on the head under review. The sequence is a real workflow: switch to a profile, run a first turn, fix the profile, switch again.

Suggested remedy, checked against ConversationStats.register_llm and LLMRegistry._ensure_independent_metrics: in the carry-over branch, after new_llm.restore_metrics(registered.metrics), make stats.usage_to_metrics[usage_id] reference that same object when the usage id is not yet tracked (direct re-pointing is safe and idempotent here because register_llm only restores from usage_to_metrics when the id is not in _restored_usage_ids). Do not instead seed profile_llm.metrics before switch_llm — _ensure_independent_metrics resets a metrics object whose id the registry already tracks, so that would be discarded. Please add a regression test for the pre-init sequence (switch pre-init → send_message → edit → re-switch → assert the stats invariant).

Unrelated, pre-existing, and not required for this PR: profile LLMs switched in before initialization stay invisible to ConversationStats until their config changes, so those calls are omitted from get_combined_metrics().

🔄 CHANGES REQUESTED

delegates the swap to :meth:`switch_llm` under the usage id
``profile:{profile_name}``. The LLM registered under that id is reused
while the profile is unchanged; after the profile was edited it is
replaced by the edited config, which keeps the usage recorded so far.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "keeps the usage recorded so far" contract does not hold when the first switch to the profile happened before the agent was initialized: the registered LLM never reaches stats.usage_to_metrics because _ensure_agent_ready() wires the ConversationStats.register_llm subscriber only at initialization and skips usage ids that are already registered. On the edited re-switch, switch_llm swaps the LLM and restore_metrics copies the registered cost onto it, but ConversationStats never tracks that metrics object. Measured on this head: installed profile:fast metrics show accumulated_cost == 1.5 while stats.usage_to_metrics["profile:fast"] stays at 0.0 and get_combined_metrics() reports 0.0 — the carried-over usage is silently dropped and later profile usage accrues outside the combined view. The PR's own test invariant (stats.get_metrics_for_usage("profile:fast") is installed.metrics) fails in this path; the new tests only exercise the initialized-first variant. Fix: in the carry-over branch, after new_llm.restore_metrics(registered.metrics), also re-point stats.usage_to_metrics[usage_id] at that object when the usage id is not yet tracked (direct re-pointing is safe — register_llm only restores when the id is not in _restored_usage_ids; seeding profile_llm.metrics before switch_llm is not sufficient because LLMRegistry._ensure_independent_metrics resets a tracked metrics id). A regression test for the pre-init sequence (switch → send_message → edit → re-switch) would pin this.

assert installed.metrics is first.metrics
assert installed.metrics.accumulated_cost == 1.5
stats = conv.conversation_stats
assert stats.get_metrics_for_usage("profile:fast") is installed.metrics

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the invariant the untested pre-init path violates: when the first switch precedes agent initialization, stats.usage_to_metrics["profile:fast"] ends up as a different Metrics object than installed.metrics (measured on this head: entry at 0.0 while the installed LLM carries the restored 1.5, get_combined_metrics() still 0.0). Please add a variant of this test that performs the first switch_profile before send_message, then edits the profile and re-switches, and asserts this same line — it currently passes only because both switches happen after initialization.

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.

[Bug]: switch_profile reinstalls the copy cached at the first switch after the profile file was edited

3 participants