fix(agent-server): base_state.json as single source of truth for the agent (end meta.json duplication) - #4440
fix(agent-server): base_state.json as single source of truth for the agent (end meta.json duplication)#4440enyst wants to merge 12 commits into
Conversation
…r the agent The agent-server persisted a conversation's agent (LLM + condenser + tools) in TWO files: base_state.json (ConversationState) and meta.json (StoredConversation, which extended StartConversationRequest). On resume the agent was rebuilt from meta.json and overwrote base_state.json, so meta.json silently won. A model switch written to one file but not the other was reverted on an idle-eviction reload. This removes the duplication at its root: - SDK: extract ConversationConfig (everything except the agent) as the shared base. StartConversationRequest adds the agent; StoredConversation now extends the agent-less ConversationConfig, so the agent cannot appear in meta.json by construction. - SDK: ConversationState.create() and LocalConversation accept agent=None; on resume the persisted base_state.json agent is kept (a durable switch_llm/ switch_acp_model survives reload). Passing an explicit agent keeps the legacy verify-and-override behavior for back-compat. - agent-server: EventService takes the new-conversation agent separately and, on resume, loads it from base_state.json. switch_acp_model no longer mirrors the model into meta.json (the SDK persists it to base_state); the credential scrub and codex detection read the agent from base_state / the live conversation; telemetry reads the live agent. Old meta.json files with an 'agent' key still load (unknown keys are ignored), so no migration is needed. Adds regression coverage: base_state-authoritative resume at the SDK level, and an end-to-end check that meta.json has no agent and a fresh service reloads the agent from base_state.json. Note: the ACP/Codex-subscription persistence paths are covered at unit level only; they were not exercised against a live ACP/Codex session. Co-authored-by: smolpaws <engel@enyst.org>
…ange Removing the agent field from StoredConversation (it no longer extends StartConversationRequest) is a breaking API change, which the api-breakage check requires a minor version bump for. Co-authored-by: smolpaws <engel@enyst.org>
…ation change" This reverts commit e718eda.
Python API breakage checks — ✅ PASSEDResult: ✅ PASSED |
REST API breakage checks (OpenAPI) — ✅ PASSEDResult: ✅ PASSED |
Coverage Report •
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
…rious API-breakage flag The Griffe-based Python API check flags a changed attribute *value* when the RHS of `self.agent = ...` changes. Assign the local `agent` var (backfilled from `self._state.agent` on resume) so the assigned expression matches main. No behavior change: on resume with agent=None the persisted agent is adopted. Co-authored-by: smolpaws <engel@enyst.org>
This comment was marked as outdated.
This comment was marked as outdated.
…f-truth-agent-state-2026-08-09
This comment was marked as outdated.
This comment was marked as outdated.
Co-authored-by: openhands <openhands@all-hands.dev>
…e PR - conversation_service: pop agent from request_data explicitly instead of relying on StoredConversation's extra=ignore to drop it; keep the serialized payload for the secrets_encrypted re-validation path. - conversation_service: pass the already-resolved live agent to _resolve_credential_bindings on the codex late-binding path, avoiding a redundant base_state.json disk read. - conversation_service: remove the dead getattr(stored, 'agent') fallback in _build_telemetry_context (StoredConversation no longer has an agent). - sdk: add LocalConversation.set_token_callbacks() public setter and use it from EventService instead of mutating the private _on_token attribute. - sdk: on agent=None resume, re-register client-tool classes from the persisted agent's tool specs so client tools stay executable on the direct SDK resume path (register_client_tools is idempotent). - tests: strengthen switch_acp_model test to create meta.json first, proving an existing meta.json is not given an agent mirror by the switch. Co-authored-by: smolpaws <engel@enyst.org>
This comment was marked as outdated.
This comment was marked as outdated.
…e PR - conversation_service: read base_state.json off the event loop via asyncio.to_thread in _resolve_credential_bindings' cold resume fallback, matching _load_persisted_state_sync usage elsewhere (no more blocking file I/O on the loop thread). - sdk: warn (instead of silently dropping) when client_tools are passed with agent=None on resume, since client tools are recovered from the persisted agent in that case. Co-authored-by: smolpaws <engel@enyst.org>
This comment was marked as outdated.
This comment was marked as outdated.
all-hands-bot
left a comment
There was a problem hiding this comment.
🟡 Taste Rating: Acceptable — the core design is cleaner: one durable owner for agent state, with meta.json reduced to metadata. I did not find a concrete correctness bug in the changed code paths I inspected.
[CRITICAL ISSUES]
None found.
[TESTING GAPS]
- [PR description / validation evidence] Eval evidence required before approval: This changes agent persistence and cold-hydration behavior in the agent server/SDK, including resume semantics, ACP model state surfacing, client-tool recovery, and credential scrubbing. The repository-specific review guidance says PRs that can plausibly affect agent behavior/evaluation performance should not be approved without explicit evaluation evidence and human confirmation. CI is green and the targeted regression tests are good, but I did not find an eval-monitor link or a maintainer note confirming that no agent evaluation is required for this behavioral persistence change.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
This is a medium-risk persistence/refactor change: it removes duplicated agent state from meta.json, moves cold paths to base_state.json, and touches credential handling around managed Codex auth. The implementation is well-scoped, dependency-free, and covered by focused regression tests; all GitHub checks are currently successful. The remaining risk is behavioral: resume/cold-hydration semantics are central to agent execution and can affect production conversations if a missed path still assumesStoredConversation.agentexists or if persisted state compatibility differs in the field.
VERDICT:
📝 Comment / approval held: Code looks sound from this review, but I am not approving until the required evaluation evidence or explicit human confirmation is added.
KEY INSIGHT:
The PR improves the data model by eliminating the meta/base_state split-brain, but that makes base_state cold-hydration the critical compatibility boundary and warrants eval-backed confidence.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger 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.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- 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
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation
…reload Drives the real ConversationService + on-disk persistence (no mocks of the conversation/event-service/SDK) to prove the reported bug is fixed end to end: start -> switch_llm at runtime -> base_state.json owns the agent -> idle eviction (and separately, a full service restart) -> rehydrate -> the switched model survives. Also asserts meta.json never carries the agent and that unrelated persisted state (tags, confirmation policy) round-trips intact. Verified meaningful via a local mutation (dropping the state persistence in switch_llm turns the eviction/restart/preserve tests red; the pure new-conversation test stays green). Co-authored-by: smolpaws <engel@enyst.org>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Overview
This PR eliminates the dual source of truth for the conversation agent by making base_state.json the single source of truth and removing agent from StoredConversation (meta.json). The approach — extracting ConversationConfig as an agent-less shared base — is clean and makes the duplication structurally impossible rather than convention-enforced.
I verified the key flows against the current head SHA (2a31835):
- Resume path:
ConversationState.create(agent=None)correctly keeps the persisted agent frombase_state.jsonuntouched, so a durableswitch_llm/switch_acp_modelsurvives an idle-eviction reload or full restart. Bothswitch_llmandswitch_acp_modelsetself._state.agent, which triggers the autosave path. - New conversation path:
EventService.start()checksbase_state_exists; for a new conversation it deep-copies the caller-supplied agent and passes it toLocalConversation. Theagent_payload = request_data.pop("agent", None)explicitly removes the agent fromrequest_databefore splatting intoStoredConversation, avoiding reliance onextra="ignore". - Credential scrub:
_scrub_persisted_credentialscorrectly targetsstate.agent(base_state.json) andconversation.agent(live)._without_stored_secretcorrectly no longer tries to scrub a non-existentstored.agent. - Fork path: The agent is persisted to the fork's
base_state.jsonviasource_conversation.fork, then_start_event_serviceis called withagent=fork_agent. Correct. - Token streaming: The deferred streaming decision (
streaming_decided) is a clean solution to the agent-unknown-at-construction-time problem on resume.set_token_callbacks(None)correctly disables streaming post-construction when the resolved agent can't emit token callbacks. - Backward compatibility: Old
meta.jsonfiles containing anagentkey still load (Pydantic ignores unknown keys). No migration needed. - Public SDK API: The
Conversationfactory still requiresagent: AgentBase(non-Optional).LocalConversation.__init__andConversationState.create()widened toAgentBase | None— backward compatible for existing callers.StoredConversationis agent-server internal (not inopenhands.sdk.__all__). No version bump required by the SDK API breakage policy.
All prior review threads (12) are resolved, and the latest commit adds real-like E2E tests (test_switch_llm_survives_reload.py) that drive the actual ConversationService against real on-disk persistence — directly addressing the E2E testing gap raised in earlier reviews. The test's _switch_llm helper matches the real /switch_llm endpoint path exactly.
Findings
1. Synchronous base_state.json read on the event loop (minor)
At line 1290, self._agent_from_base_state(conversation_id) is called synchronously inside the async _start_conversation method. This does a blocking read_text() + model_validate_json() on base_state.json on the event-loop thread. The same operation in _resolve_credential_bindings (line 777) was correctly wrapped in asyncio.to_thread, but this call site was not — even though the comment at line 774-775 explicitly references mirroring the _load_persisted_state_sync usage elsewhere, and _conversation_info (line 819) also wraps the same read in asyncio.to_thread.
This is a cold path (reuse of an existing record with codex credentials), and base_state.json is typically small, so the impact is low. But for consistency with the pattern established elsewhere in this PR, consider wrapping it: agent = await asyncio.to_thread(self._agent_from_base_state, conversation_id).
Risk Assessment
Low-Medium. The design is sound and well-tested. All prior review findings have been addressed across multiple commits. The new E2E tests close the testing gap. The remaining risk is behavioral: resume/cold-hydration semantics are central to agent execution, and the ACP/Codex persistence paths (while structurally correct) are still covered primarily at the unit level for the ACP-specific code paths — the PR description notes the author tested the ACP/Codex path live manually.
No security issues found. No correctness bugs in the changed code paths.
Verdict
No blocking issues. The one inline finding is a minor consistency nit on a cold path. The PR is in good shape for merge pending the standard eval/behavioral validation gate.
…to_thread Second call site of the same pattern: _start_conversation's reattach branch read base_state.json synchronously on the event loop to detect a codex agent. Wrap it in asyncio.to_thread like _resolve_credential_bindings and _conversation_info already do. Cold path, low impact, but keeps the read off the loop thread consistently. Co-authored-by: smolpaws <engel@enyst.org>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
This PR eliminates the dual source of truth for the conversation agent by removing agent from StoredConversation (meta.json) and making base_state.json (ConversationState) the single source. The approach is sound: extracting ConversationConfig as a shared agent-less base class makes the duplication structurally impossible rather than relying on convention.
Correctness Assessment
Core design — no material bugs found. I traced every code path that previously read stored.agent:
_resolve_credential_bindings: now takesagentexplicitly or falls back to_agent_from_base_state(off the event loop viaasyncio.to_thread). ✅_start_conversationreattach path: reads agent frombase_state.jsonviaasyncio.to_thread. ✅_compose_conversation_info: already usedstate.agent(notstored.agent). ✅_build_telemetry_context: takesagentexplicitly from the live conversation. ✅_without_stored_secret: correctly simplified — only scrubsstored.secretssincestoredno longer carries the agent. Agent-context scrub still happens onbase_state.jsonin_scrub_persisted_credentials. ✅switch_acp_model: no longer mirrors to meta.json; relies onLocalConversation.switch_acp_modelsettingstate.agentwhich triggers autosave to base_state.json. ✅- Fork path: fork's base_state.json is written by
source_conversation.fork(), andEventService.start()correctly takes the resume path (base_state_exists=True). Thefork_agentpassed to_start_event_serviceis used for credential binding but not for conversation construction (which loads from base_state.json). ✅
Token streaming on resume: The deferred-decision pattern is correct — streaming defaults to True when the agent is unknown (resume), then _agent_can_stream(conversation.agent) is checked post-construction and disabled via set_token_callbacks(None) if needed. No tokens are emitted between construction and the check. ✅
Backward compatibility: Old meta.json files containing an agent key load fine because ConversationConfig (and therefore StoredConversation) uses Pydantic's default extra="ignore". The removed agent_profile_id field had exclude=True so it was never persisted to meta.json — the removal is safe. ✅
Test Coverage
Excellent. The new test files directly reproduce the user-reported bug end-to-end:
test_switch_llm_survives_reload.py: drives the realConversationServicewith real on-disk persistence through start → switch_llm → idle eviction → rehydrate, and through a full service restart. Also verifies meta.json has no agent and unrelated state (tags, confirmation policy) survives.test_base_state_single_source.py: pins the SDK-levelConversationState.create(agent=None)resume behavior and the explicit-agent override path.- Updated
test_credential_binding.py: now asserts"agent" not in metaand verifies scrubbing happens on base_state.json / live conversation instead.
Risk Assessment: Low
The breaking change (StoredConversation no longer carries agent) is internal to the agent-server persistence layer and does not affect the REST API surface — ConversationInfo still exposes agent from state.agent. External code reading stored.agent directly would get an AttributeError, but no such references remain in the codebase. The SDK ConversationState.create() signature change (agent: AgentBase | None) is additive — existing callers passing a non-None agent keep the legacy verify-and-override behavior.
Minor Observations (non-blocking)
-
The fork comment at
conversation_service.py:1790-1793says the agent is passed "for the new-conversation path," butEventService.start()actually takes the resume path for forks (base_state_exists=True). Thefork_agentis used for credential binding detection, not conversation construction. The code is correct; the comment could be clearer. -
On resume,
_token_streaming_callbackis created and passed toLocalConversation, then potentially discarded viaset_token_callbacks(None). This is correct and safe (no tokens fire between construction and the check), but slightly wasteful. Not worth changing.
VascoSch92
left a comment
There was a problem hiding this comment.
Left a couple of comments :-)
| # persisted agent. On a new conversation the creating caller supplied the | ||
| # agent via ``self.agent``; deep-copy it (expose_secrets) so the running | ||
| # agent is independent of the caller's object. | ||
| base_state_exists = (self.conversation_dir / BASE_STATE).exists() |
There was a problem hiding this comment.
base_state_exists check overrides a freshly-supplied agent with None whenever base_state.json already exists, so a retried "new conversation" start silently keeps a stale agent from a prior failed attempt.
Also a blocking .exists() filesystem call left un-threaded on the event loop, inconsistent with this PR's own to_thread fix for the sibling read.
There was a problem hiding this comment.
base_state_exists check overrides a freshly-supplied agent with None whenever base_state.json already exists, so a retried "new conversation" start silently keeps a stale agent from a prior failed attempt.
Sorry, what do you mean by prior failed attempt?
Any conversation is saved in conversation_dir/id/, and if you try to restore an id, then, well, it tries to restore it. If it fails, you can make a new conversation, right?
There was a problem hiding this comment.
smolpaws here 🐾 (working with Engel). Thanks for the careful read — we looked into it and don't think the "stale agent" case arises.
An Agent is immutable, and no path overrides one agent with another in the same conversation. So for a given id the agent is only ever:
- already persisted → loaded (
agent=Noneresume branch). Passing a different agent here isn't a retry; it's an agent swap, whichstart_conversationdeliberately doesn't do (switch_llmis the only mutator). - not yet there → the supplied agent is saved as base_state.
Either way it can't be stale — it's existing-and-loaded or new-and-saved. A same-id retry carries the same agent. Is there a caller you had in mind where a genuinely different agent reaches this path?
On the .exists(): fair, it's a sync stat() — but start() already does other sync FS work around it (mkdir, git checks), so threading just this leaf felt inconsistent. Happy to thread the block if you'd prefer.
| # supply an agent (legacy behavior), verify tool compatibility and | ||
| # let it override, so existing callers that reconfigure on resume | ||
| # keep working. | ||
| if agent is not None: |
There was a problem hiding this comment.
if agent is not None: gates agent.verify(). Since the agent-server always resumes with agent=None, tool-consistency verification never runs on real resume.
There was a problem hiding this comment.
🐾 Right that verify() no longer runs when agent=None — but the check now sits exactly where tools can change on resume:
- Canvas (agent-server): can't change tools on resume. MCP edits write to
agent_settings.mcp_config, read only at creation (createConversation→ fresh uuid); resume isPOST /runwith no agent. Onmain,verify()compared two copies of the same persisted agent — a self-check. So: same behavior both branches, nothing real lost. - CLI / direct SDK: can change tools on resume (
load_agent_specs()builds an agent and passes it). Hereagent is not None, soverify()still runs — removal errors, addition allowed. Guard intact.
So it's gated to the callers who can actually mutate tools. One honest gap: the agent-server path has no backstop now — unreachable today, but happy to add a resume-time check if you'd like.
There was a problem hiding this comment.
(HUMAN) Please see below too. I don’t know if overriding tools is a thing, as of now.
So I’d prefer to straighten out the architectural smell here, and then worry about flexibility on tools later. (Pretty sure we could see more possible changes if we want to go for tools flexibility, as opposed to the current “mostly frozen except for MCP in CLI” or so.)
There was a problem hiding this comment.
I’ll try to double check though, for some tools weirdnesses from canvas. I am currently using the branch in my regular canvas for the last days, FWIW; just to see if strange things happen.
| # from those persisted specs (done below, after the agent is loaded). | ||
| resolved_client_tools = list(client_tools or []) | ||
| if not resolved_client_tools and persistence_dir is not None: | ||
| if agent is None and resolved_client_tools: |
There was a problem hiding this comment.
client_tools passed alongside agent=None on resume are only logged, never injected.
There was a problem hiding this comment.
🐾 Agreed "ignored" reads as "dropped," but they aren't lost on resume:
- New conversation: caller
client_toolsare injected (L345). - Resume (
agent=None): the persisted agent already carries the tool specs; just below (L380+) weextract_client_tool_specs+register_client_toolsfrom those, so they stay executable. Re-injecting the caller's would risk overriding the persisted set.
So on resume they're redundant, not dropped. The only real fix is the wording — happy to reword to "recovered from the persisted agent; caller-supplied specs not re-injected." Want me to?
There was a problem hiding this comment.
(HUMAN) ^^ The point here is also that, according to my agent, nothing is changing tools on main via agent-server. The old CLI does; but that’s via Python API, not REST API. Via REST, we won’t and don’t “override” tools currently. So I don’t think this is a “loss” of behavior?
Two review nits from Vasco on the get-or-create reattach branch of _start_conversation: - V5: read the live agent via the public get_conversation() instead of reaching into event_service._conversation, and drop the dead 'is None' branch (is_open() already guarantees a live conversation here). - V4: thread the already-parsed reattach_agent into _get_or_load_event_service_locked -> _start_event_service so the load path does not read and parse base_state.json a second time. No behavior change on resume (base_state.json remains the single source of truth). 114 agent-server conversation tests pass; ruff clean. Co-authored-by: smolpaws <engel@enyst.org>
|
🚦 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. |
HUMAN:
This PR proposes to fix the two sources of truth issue in
agent-serverat the root: the SDK normally saves the conversation state including agent inbase_state.json, but the server does its own thing where it saves the agent inmeta.json, then creates a conversation which saves it again inbase_state.json.This is a code design fault; it leads to the code needing to save a setting change in both places, which, at its turn, leads to bugs when one place is forgotten, or one place is overridden when the user changes something (like LLM profile), but at restore, the server reads only the other place.
Reported on Slack and on GitHub:
Related PRs
Earlier on-spot attempts at the same problem (this PR supersedes them by removing the duplication at the root):
meta.jsonso it survives a restart (fixes the symptom by writing the second file).base_state.jsonauthoritative for the resume agent.base_stateauthoritative on resume for switched llm/condenser + acp_model.AGENT:
Why
The agent-server persisted a conversation's agent (LLM + condenser + tools) in two files:
base_state.json(ConversationState) andmeta.json(StoredConversation, which extendedStartConversationRequest). On resume the agent was rebuilt frommeta.jsonand assigned over the state loaded frombase_state.json— someta.jsonsilently won, andbase_state.json's agent was only used to verify tool compatibility. A model switch persisted to one file but not the other was therefore reverted on an idle-eviction reload.Design writeup (a/b/c alternatives + the resume/reattach edge): https://enyst.github.io/arch/meta-vs-base-state-alternatives.html
Summary
ConversationConfig(everything except the agent) as a shared base.StartConversationRequestadds the agent;StoredConversationnow extends the agent-lessConversationConfig, so the agent cannot appear inmeta.jsonby construction. Breaking:StoredConversationno longer carriesagent.ConversationState.create()andLocalConversationacceptagent=None; on resume the persistedbase_state.jsonagent is kept (a durableswitch_llm/switch_acp_modelsurvives reload). Passing an explicit agent keeps the legacy verify-and-override behavior.EventServicetakes the new-conversation agent separately and, on resume, loads it frombase_state.json.switch_acp_modelno longer mirrors intometa.json; credential scrub, codex detection, and telemetry read the agent frombase_state/ the live conversation.Issue Number
#4032 — LLM profile timeout is reset after agent-server restart (same root cause: the agent/LLM state was not authoritative on reload). Also surfaced in the OpenHands Slack #general thread (Neal’s docker-vs-native / model-switch report).
How to Test
Ran locally against this branch:
ruffandpyrightare clean; the repo pre-commit hooks pass.Compatibility: old
meta.jsonfiles that still contain anagentkey load fine (Pydantic ignores unknown keys), so no migration is needed.Video/Screenshots
N/A — server-side persistence change; covered by the automated tests above.
Type
HUMAN:
switch_acp_model:codex-switch-llm.mov
Result:
{"id":"6fba40bd-7676-4415-af5f-316c32787a1b","agent":{"llm":{"model":"gpt-5.6-sol",Co-authored-by: smolpaws engel@enyst.org
🐳 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
eclipse-temurin:17-jdknikolaik/python-nodejs:python3.13-nodejs22-slimgolang:1.21-bookwormPull (multi-arch manifest)
# Each variant is a multi-arch manifest supporting both amd64 and arm64 docker pull ghcr.io/openhands/agent-server:d50054f-pythonRun
All tags pushed for this build
About Multi-Architecture Support
d50054f-python) is a multi-arch manifest supporting both amd64 and arm64d50054f-python-amd64) are also available if needed