fix(agent-server): persist LLM/profile switch to meta.json so it survives restart#4028
fix(agent-server): persist LLM/profile switch to meta.json so it survives restart#4028VascoSch92 wants to merge 6 commits into
Conversation
…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.
…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.
enyst
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:
Make it sense, or? |
…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.
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 tobase_state.json). On resume,EventService.start()rebuilds the agent frommeta.json(self.stored.agent) andConversationState.create()overwrites the just-loadedbase_state.jsonagent with it (state.agent = agent). Because the switch was never mirrored intometa.json, the creation-time LLM wins on every restart.There are three ways the live LLM changes, and all three were affected: the
/switch_llmendpoint (app-servers, #3017), the/switch_profileendpoint (agent-canvas), and the agent's own built-inswitch_llmtool (SwitchLLMExecutor→conversation.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 tometa.json; the LLM/profile paths were missing it.Summary
EventService._sync_llm_to_meta()helper that writes a swapped LLM back intometa.json(save_meta), matching the existingswitch_acp_modelprecedent./switch_llmand/switch_profileendpoints through newEventServicemethods that call the helper, and call it from the post-run hook so the agent's in-runswitch_llmtool is covered too — all three switch paths now survive a restart.llm/condenserare updated (not the whole live agent), keyed on LLM object identity. This keeps the stored agent plugin-unmerged —_ensure_plugins_loadedmerges pluginagent_context/mcp_configinto 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):
test_switch_llm_survives_restart(endpoint path) starts a realConversationService, creates a conversation whose LLM hastimeout=300, switches to an LLM withtimeout=600, tears the service down, then starts a freshConversationServiceon the same directory (a genuine resume from disk) and asserts the resumed conversation still reportstimeout=600.test_in_run_switch_llm_persists_to_meta(tool path) starts a realEventService, swaps the live LLM directly on the conversation (simulating the agent'sswitch_llmtool, bypassing the endpoints), drives a real run to completion with a scriptedTestLLM, and asserts the post-run hook wrotetimeout=600intometa.json.test_sync_llm_to_meta_ignores_plugin_mergeguards 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):
timeout.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):
Tool path — pre-fix (post-run hook removed):
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, andpyrightare clean (all pre-commit hooks pass).Type
Notes
llmandcondenser(the fields aswitch_llmchanges: condenser is disabled for subscription LLMs / rebuilt for the new model), so the condenser LLM'stimeoutis fixed on resume too — not just the primary LLM.save_metaserializes with the cipher context, so at-rest secret encryption is unchanged.meta.json.switch_acp_model) — untouched.404unknown conversation,404unknown profile,400corrupted profile).