Skip to content

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

Open
VascoSch92 wants to merge 6 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 6 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.
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

5 participants