Skip to content

fix(agent-server): persist LLM/profile switch to meta.json so it survives restart - #4028

Open
VascoSch92 wants to merge 11 commits into
OpenHands:mainfrom
VascoSch92:fix/persist-llm-switch-to-meta
Open

fix(agent-server): persist LLM/profile switch to meta.json so it survives restart#4028
VascoSch92 wants to merge 11 commits into
OpenHands:mainfrom
VascoSch92:fix/persist-llm-switch-to-meta

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Jul 8, 2026

Copy link
Copy Markdown
Member

HUMAN:

Fix bug with restart.


AGENT:

Why

When the agent-server restarts (e.g. the container is stopped and the conversation is reopened), a conversation's LLM config — including timeout — silently reverts to the values captured at conversation creation. Switching the LLM profile appears to fix it, but the fix is lost again on the next restart.

Root cause: switching the LLM updates only the live ConversationState (persisted to base_state.json). On resume, EventService.start() rebuilds the agent from meta.json (self.stored.agent) and ConversationState.create() overwrites the just-loaded base_state.json agent with it (state.agent = agent). Because the switch was never mirrored into meta.json, the creation-time LLM wins on every restart.

There are three ways the live LLM changes, and all three were affected: the /switch_llm endpoint (app-servers, #3017), the /switch_profile endpoint (agent-canvas), and the agent's own built-in switch_llm tool (SwitchLLMExecutorconversation.switch_profile, which bypasses the HTTP layer entirely). The ACP model-switch path (switch_acp_model) already handles exactly this by writing the change back to meta.json; the LLM/profile paths were missing it.

Summary

  • Add a single EventService._sync_llm_to_meta() helper that writes a swapped LLM back into meta.json (save_meta), matching the existing switch_acp_model precedent.
  • Route the /switch_llm and /switch_profile endpoints through new EventService methods that call the helper, and call it from the post-run hook so the agent's in-run switch_llm tool is covered too — all three switch paths now survive a restart.
  • Only the stored agent's llm/condenser are updated (not the whole live agent), keyed on LLM object identity. This keeps the stored agent plugin-unmerged_ensure_plugins_loaded merges plugin agent_context/mcp_config into the live agent on first run, and persisting that would double-merge on resume — and makes the post-run hook a no-op for ordinary/plugin-loading runs.

Issue Number

Fix #4032
Addresses the report: "agent-server sets wrong LLM timeouts when it starts; switching the LLM profile sets the correct values." Reproduction below.

How to Test

Automated (real resume from disk / real agent run, not just unit mocks):

uv run pytest \
  tests/agent_server/test_conversation_service.py::test_switch_llm_survives_restart \
  "tests/agent_server/test_event_service.py::TestEventServiceSaveMeta" \
  tests/agent_server/test_conversation_router.py -k switch
  • test_switch_llm_survives_restart (endpoint path) starts a real ConversationService, creates a conversation whose LLM has timeout=300, switches to an LLM with timeout=600, tears the service down, then starts a fresh ConversationService on the same directory (a genuine resume from disk) and asserts the resumed conversation still reports timeout=600.
  • test_in_run_switch_llm_persists_to_meta (tool path) starts a real EventService, swaps the live LLM directly on the conversation (simulating the agent's switch_llm tool, bypassing the endpoints), drives a real run to completion with a scripted TestLLM, and asserts the post-run hook wrote timeout=600 into meta.json.
  • test_sync_llm_to_meta_ignores_plugin_merge guards the plugin case: a plugin-style agent swap (new agent object, same LLM) must not be persisted, so the stored agent stays unmerged. (Verified it fails if the whole live agent is mirrored instead.)

Manual reproduction (matches the report):

  1. Give a conversation an active LLM profile with a non-default timeout.
  2. Stop the agent-server / container, reopen the conversation so it restarts and reconnects.
  3. cat /workspace/conversations/<SESSION>/*.json | jq '.. | .timeout? // empty' — before this change the timeout reverts to the creation-time value; after it, the switched value persists.

Video/Screenshots

End-to-end evidence that the fix is exercised (not unit-mocked): each regression test fails when its persistence hook is removed (pre-fix behavior) and passes with the fix.

Endpoint path — pre-fix (mirror step reverted):

$ uv run pytest tests/agent_server/test_conversation_service.py::test_switch_llm_survives_restart -q --tb=line
tests/agent_server/test_conversation_service.py:582: AssertionError: assert 300 == 600
FAILED ...::test_switch_llm_survives_restart

Tool path — pre-fix (post-run hook removed):

$ uv run pytest "...::test_in_run_switch_llm_persists_to_meta" -q
FAILED ...::test_in_run_switch_llm_persists_to_meta

Both pass with the fix. Full suites for the touched files pass locally (test_event_service.py, test_conversation_service.py, test_conversation_router.py, test_goal_loop.py — 306 total). ruff check, ruff format, and pyright are clean (all pre-commit hooks pass).

Type

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

Notes

  • Updates llm and condenser (the fields a switch_llm changes: condenser is disabled for subscription LLMs / rebuilt for the new model), so the condenser LLM's timeout is fixed on resume too — not just the primary LLM. save_meta serializes with the cipher context, so at-rest secret encryption is unchanged.
  • The post-run hook is best-effort (failures logged, never break run completion) and a no-op unless the LLM object actually changed, so ordinary and plugin-loading runs don't rewrite meta.json.
  • ACP model switches keep their existing dedicated mirroring (switch_acp_model) — untouched.
  • No API/schema changes: both endpoints keep their request/response shapes and status codes (404 unknown conversation, 404 unknown profile, 400 corrupted profile).

…ives restart

switch_llm / switch_profile updated only the live ConversationState
(base_state.json). On resume, EventService.start() rebuilds the agent from
meta.json and ConversationState.create() overwrites base_state.json with it,
so the switch — including the LLM timeout — reverted to the conversation's
creation-time config on every agent-server restart. Re-switching the profile
fixed it live but the fix was lost on the next restart.

Mirror the switched agent into meta.json (+ save_meta), matching the existing
switch_acp_model precedent, by routing both endpoints through new
EventService.switch_llm / switch_profile methods.
Extract the mirror-into-meta.json logic into a single EventService helper,
_sync_agent_to_meta(), used by switch_llm, switch_profile, and a new post-run
hook. The post-run hook covers the third switch path — the agent's built-in
switch_llm tool (SwitchLLMExecutor -> conversation.switch_profile), which
bypasses the HTTP endpoints — so tool-initiated switches also survive a
restart. An identity check against a start()-established baseline keeps the
hook a no-op for ordinary runs.
Comment thread openhands-agent-server/openhands/agent_server/event_service.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/event_service.py Outdated
Comment thread openhands-agent-server/openhands/agent_server/event_service.py Outdated
…erged

Addresses review feedback (trim comments) and a correctness gap: mirroring the
whole live agent baked plugin-merged agent_context/mcp_config into meta.json,
which would double-merge plugins on resume (_ensure_plugins_loaded merges once
per process on top of the stored agent).

Instead, surgically update the stored agent's llm/condenser only — the fields a
switch changes — mirroring the switch_acp_model precedent. Detection is keyed on
the LLM object identity (a swap replaces it; plugin loading replaces the agent
but keeps the LLM), so ordinary and plugin-loading runs stay no-ops. Add a
regression test that a plugin-style agent swap is not persisted.
@VascoSch92
VascoSch92 marked this pull request as ready for review July 8, 2026 12:12
@VascoSch92
VascoSch92 requested a review from simonrosenberg July 8, 2026 13:22

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are we sure this is the best code design? The tool firing is an event we could capture with a hook or maybe a new listener, rather than attempting to always save 🤔

Could we analyze the options a bit here?

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Issue #4032 notes:

The agent should continue using the LLM profile timeout value configured before the restart (1234) or the one of the active profile.

I see from this PR that what happened is that the entire profile switch may have not been persisted as expanded LLM. That makes me wonder, could we maybe save the profile, rather than the expanded LLM? And load it on restore.

Alternatively, since that is quite involving, for the bug fix maybe we could read the active profile at restore time?

Please consider it just quick thoughts, open for discussion.

@VascoSch92

Copy link
Copy Markdown
Member Author

Issue #4032 notes:

The agent should continue using the LLM profile timeout value configured before the restart (1234) or the one of the active profile.

I see from this PR that what happened is that the entire profile switch may have not been persisted as expanded LLM. That makes me wonder, could we maybe save the profile, rather than the expanded LLM? And load it on restore.

Alternatively, since that is quite involving, for the bug fix maybe we could read the active profile at restore time?

Please consider it just quick thoughts, open for discussion.

@enyst I did consider it.

Two reasons I went with persisting the expanded LLM instead:

  1. The /switch_llm endpoint passes an inline LLM with no profile on disk to reload, so a profile-reference scheme can't cover all three switch paths uniformly. It'd need two persistence formats. (which is not so nice)
  2. More importantly, a running conversation already freezes the profile at switch time (the expanded LLM is cached in the registry, first-write-wins, later edits to the profile file aren't picked up until an explicit re-switch). Persisting the expanded LLM keeps a restarted conversation behaving identically to one that was never restarted. Reloading the profile at restore would instead make a restart silently pull in profile edits the live process was ignoring.

Make it sense, or?

VascoSch92 and others added 2 commits July 9, 2026 13:40
…stener

Replace the post-run _sync_llm_to_meta hook with a listener on ConversationStateUpdateEvent(key="agent"), flushed at run teardown, so the in-run switch_llm tool's swap is persisted to meta.json as an event instead of polled after every run. The /switch_llm and /switch_profile endpoints keep their synchronous persist; the LLM-identity guard keeps plugin merges and ordinary runs no-ops.
@kripper

kripper commented Jul 9, 2026

Copy link
Copy Markdown

Makes sense. It's a fix for the current expanded LLM config. Thanks!

It also makes sense to deprecate the redundancy, but the name "expanded" sounds more like an expanded per-conversation config override.

In this case, the live in-memory config (of a session that has never been restarted) should be:

llm_profile_config + convo_expanded_config

And the configs should always be written to the location where the agent can load them again after a restart.

@kripper

kripper commented Jul 9, 2026

Copy link
Copy Markdown

and architectural changes are better handled in a separate PR

@kripper

kripper commented Jul 10, 2026

Copy link
Copy Markdown

I cherry-picked the PR and on my env the issue is still present, ie. agent-server resets the LLM timeout once OH reconnects.
I will continue testing...

@enyst

enyst commented Jul 10, 2026

Copy link
Copy Markdown
Member

Just to note, for investigation of the best solution here:

The /switch_llm endpoint passes an inline LLM with no profile on disk to reload, so a profile-reference scheme can't cover all three switch paths uniformly. It'd need two persistence formats. (which is not so nice)

Doesn’t the endpoint change the “active” profile? If not, I think maybe it should. The human-triggered switch does… 🤔

On a side note, we already have two persistence formats, that is why I think it’s worth thinking how to reduce them to one: profiles, as much as possible. Maybe the agent is confused here, though maybe it’s me. 😅

@kripper

kripper commented Jul 10, 2026

Copy link
Copy Markdown

I cherry-picked the PR and on my env the issue is still present, ie. agent-server resets the LLM timeout once OH reconnects.
I will continue testing...

I tested again with latest version of OH:

  • with agent-server (latest version) => issue is present
  • with agent-server (this PR) => issue is solved

So the PR solves the issue.

@enyst

enyst commented Jul 13, 2026

Copy link
Copy Markdown
Member

@OpenHands describe what does the PR do, if the user has a conversation which is started, works, then later restored, but they never used any of switch llm profile during this conversation.

reply directly on gh.

@openhands-ai

openhands-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown

I'm on it! enyst can track my progress at all-hands.dev

enyst commented Jul 13, 2026

Copy link
Copy Markdown
Member

If the conversation never switches LLM/profile, this PR does not change its behavior.

At conversation creation, the expanded LLM config is stored in meta.json. On the first start, this PR records that LLM object as the already-synced baseline. Ordinary runs (including plugin loading) keep the same LLM object, so the new sync path is a no-op and does not rewrite meta.json. When the conversation is restored, it is rebuilt from the same expanded LLM snapshot in meta.json.

Therefore:

  • if the desired timeout was already in meta.json when the conversation was created, that value is used after restore;
  • if the profile file/active profile was changed later, but the conversation never switched to it, this PR does not reload that profile on restore—the conversation continues using its creation-time expanded config.

So this PR specifically makes an actual live LLM/profile switch durable; it does not change profile resolution for conversations that never switched.

This reply was generated by an AI agent (OpenHands) on behalf of the user.

@openhands-ai

openhands-ai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Replied directly on GitHub: #4028 (comment)

The response clarifies that the PR is a no-op for conversations that never switch LLM/profile: restoration continues using the creation-time expanded LLM snapshot from meta.json, and later profile-file changes are not automatically reloaded.

@kripper

kripper commented Jul 18, 2026

Copy link
Copy Markdown

FYI, I depend on this PR (or a similar solution).

@VascoSch92

Copy link
Copy Markdown
Member Author

@OpenHands solve the conflicts

@openhands-ai

openhands-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown

I'm on it! VascoSch92 can track my progress at all-hands.dev

@VascoSch92

Copy link
Copy Markdown
Member Author

@enyst what do you think?

Resolve conflict in tests/agent_server/test_conversation_service.py:
keep both test_switch_llm_survives_restart (this PR) and
test_startup_and_search_do_not_hydrate_idle_conversation (main).
Update the restart assertion to explicitly hydrate via get_event_service
now that persisted conversations are loaded lazily (OpenHands#4100).

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

Copy link
Copy Markdown
Member Author

Resolved the merge conflicts with main (pushed as b8f4ca6).

The only conflict was in tests/agent_server/test_conversation_service.py, where both branches added a new test at the same spot:

  • kept this PR's test_switch_llm_survives_restart
  • kept main's test_startup_and_search_do_not_hydrate_idle_conversation

One semantic adjustment was needed: main's #4100 ("Lazily hydrate persisted conversations") means idle conversations are no longer eagerly loaded on restart, so the restart assertion now explicitly hydrates via await restarted.get_event_service(conversation_id) (the genuine resume-from-meta.json path) instead of reading restarted._event_services.get(...) directly.

Verification:

  • test_switch_llm_survives_restart passes.
  • Full touched suites pass: test_conversation_service.py, test_event_service.py, test_conversation_router.py — 297 passed.
  • ruff check and ruff format --check clean on the touched file.

The PR is now mergeable.

This comment was created by an AI agent (OpenHands) on behalf of the user.

VascoSch92 and others added 2 commits July 23, 2026 14:08
Preserve both ACP credential binding activation and the LLM persistence baseline during EventService startup.

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

Copy link
Copy Markdown
Member Author

@enyst :-)

@OpenHands OpenHands deleted a comment from openhands-ai Bot Jul 24, 2026
@OpenHands OpenHands deleted a comment from all-hands-bot Jul 24, 2026
Adds the QA harness used to check whether the LLM timeout really is lost
across an agent-server restart, and under which conditions.

- timeout_restart_evidence.py: end-to-end reproduction against a real
  agent-server process (SIGKILLed and restarted on the same persistence
  dirs), a real mock LLM and a real agent run. Reads the timeout three
  ways: cold, warm, and behaviourally by stalling the LLM and measuring
  when the agent gives up.
- no_switch_repro.py: the three no-switch paths (plain agent, agent
  profile launch, profile edited after creation), in-process and offline.
- mock_llm.py: OpenAI-compatible mock with a switchable stall mode.
- RESULTS.md: the recorded runs.

Findings: the revert reproduces only when a switch leaves the live LLM
diverged from meta.json, which is what this PR fixes; without a switch
the creation-time timeout already survives, including at the commit the
issue was filed against. Also documents that a cold read right after a
restart is served from base_state.json and still reports the pre-restart
value, so manual verification has to touch the conversation first.
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. Because this is a fork PR, the directory will be automatically removed from main immediately after merge.

…penHands#4032)

Answers the question: profile timeout=600, made active, NEW conversation
started from it, never switched — is it 600 or 300 after an agent-server
restart? Independent of the existing harness: dumps both parsed paths and raw
byte tokens from meta.json and base_state.json, then restarts the service over
the same dir and reads the live agent timeout.

Result on this branch AND on main: 600 -> 600. The active profile's LLM is
copied into the agent at creation and written to both files; with no switch the
two never diverge, so the meta.json rebuild reinstates the same 600. The 300
default only surfaces when a switch isn't mirrored into meta.json.

Co-authored-by: smolpaws <engel@enyst.org>

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👋 I'm an AI agent (Opus 5) reviewing this PR on behalf of Engel Nyst (@enyst).

🔴 Needs improvement - the synchronization marker is committed before the durable state it is meant to represent.

[CRITICAL ISSUES]

  • [openhands-agent-server/openhands/agent_server/event_service.py, Line 1676] Persistence Correctness: _synced_llm advances before save_meta() succeeds. The callback deliberately catches persistence errors, so one transient failed write leaves meta.json stale while the in-memory marker says this exact LLM is already synchronized; every later agent update then returns at the identity guard and never retries. Update the marker only after a successful save. Keep the candidate StoredConversation local until persistence succeeds as well, so the marker, in-memory metadata, and disk commit advance together.

[TESTING GAPS]

  • [tests/agent_server/test_event_service.py] Missing Failure-Retry Proof: add a test where the first save_meta() fails and a later matching agent-state update retries and persists the switched LLM. The current exception tests do not prove recovery after a transient disk failure.

KEY INSIGHT:
A synchronization watermark must represent committed durable state, not an attempted write.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM
    The change is localized, but it modifies restart persistence for live LLM/profile switching; a failed write currently defeats the PR's core guarantee until another distinct switch occurs.

VERDICT:
Needs rework: make the durable write succeed before advancing the synchronization marker.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@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.

Review Summary

This PR correctly identifies the root cause (resume rebuilds the agent from meta.json and ConversationState.create() overwrites the live base_state.json agent) and mirrors the three switch paths through a single _sync_llm_to_meta() helper, matching the existing switch_acp_model precedent. The plugin-merge guard (LLM identity check) is the right call — mirroring the whole live agent would bake in merged agent_context/mcp_config and double-merge on resume. The endpoint tests were correctly migrated from mocking conversation.switch_* to event_service.switch_*. Overall the fix is sound and the e2e resume-from-disk test is valuable.

Material findings

1. Synchronization watermark advances before save_meta() commits (correctness).
In _sync_llm_to_meta(), self._synced_llm = conversation.agent.llm (line 1676) and self.stored = ... (line 1675) are set before await self.save_meta() (line 1677). If save_meta() raises, the in-memory marker and self.stored already reflect the new LLM, but meta.json on disk does not. The _on_conversation_event listener swallows that exception (except Exception: logger.exception(...)), so the failed write is not retried: every subsequent agent-change event hits the identity guard and returns early, so the switched LLM never reaches meta.json until a different LLM is installed. This silently defeats the PR's core guarantee on a transient disk error. Move both the self.stored update and the _synced_llm advance to after a successful save_meta(), keeping the candidate StoredConversation local until persistence succeeds. The endpoint paths (switch_llm/switch_profile) propagate the exception (good), but the in-run tool path through the listener does not retry.

2. .pr/ dev artifacts are committed and this is a fork PR (cannot be auto-removed).
Per the repo's PR_ARTIFACTS policy, .pr/ is PR-only reviewer context that the pr-artifacts.yml workflow auto-removes on approval — but that auto-cleanup cannot push to forks. Since the head branch lives on VascoSch92/software-agent-sdk, these 5 files (RESULTS.md, engel_active_profile_question.py, mock_llm.py, no_switch_repro.py, timeout_restart_evidence.py, ~1100 lines) must be manually removed before merge or they will land on main. None of them are referenced by the actual fix or its tests; they are repro/evidence scripts. Note some contain placeholder API keys (api_key="sk-x", "api_key": "sk-mock"); these are test-only mock values, not real secrets, but they should still not ship in the tree.

Non-blocking observations (no action required for merge)

  • The post-run wait_for_pending flush (lines 1198–1204) is a belt-and-suspenders backstop for the in-run tool path. The _meta_sync_listener is the second callback in the composed chain, so its coroutine is already scheduled by the time the first wait_for_pending (for _callback_wrapper) runs; the second flush mainly guards against the schedule-but-not-complete window. Reasonable as-is.
  • _meta_sync_listener is not explicitly drained/closed in close(), but it follows the same lifecycle as _callback_wrapper (which is also not explicitly closed there), so this is consistent with the existing pattern.
  • _sync_llm_to_meta mirrors llm and condenser only. For an ACPAgent, switch_acp_model handles acp_model/agent_state separately via its own dedicated path, so the two paths don't conflict. If a future agent type stored switch-relevant state in other top-level fields, this would need revisiting, but that's not the case today.

Risk assessment: MEDIUM

The change is localized and the design (identity-guarded partial mirror) is correct for the plugin case. The main correctness gap is the watermark-before-commit ordering, which only manifests on a failed save_meta() (transient disk/permission error) but is easy to fix. The committed .pr/ artifacts are a merge-hygiene blocker for a fork PR.

This review was created by an AI agent (OpenHands) on behalf of the repository reviewer.

}
)
self.stored = self.stored.model_copy(update={"agent": agent})
self._synced_llm = conversation.agent.llm

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.

Watermark advances before the durable commit. self.stored (line 1675) and self._synced_llm (here, line 1676) are set before await self.save_meta() (line 1677). If save_meta() raises, the listener swallows the exception (except Exception: logger.exception(...) in _on_conversation_event), so the failed write is never retried — every later agent-change event returns at the identity guard and the switched LLM never reaches meta.json. Move both mutations to after a successful save_meta(), keeping the candidate StoredConversation local until persistence succeeds. A test where the first save_meta() fails and a later matching update retries would lock this in.

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]: LLM profile timeout is reset after agent-server restart

7 participants