Skip to content

fix(agent-server): base_state.json as single source of truth for the agent (end meta.json duplication) - #4439

Closed
enyst wants to merge 3 commits into
OpenHands:mainfrom
enyst:fix/single-source-of-truth-agent-state-2026-08-09
Closed

fix(agent-server): base_state.json as single source of truth for the agent (end meta.json duplication)#4439
enyst wants to merge 3 commits into
OpenHands:mainfrom
enyst:fix/single-source-of-truth-agent-state-2026-08-09

Conversation

@enyst

@enyst enyst commented Aug 9, 2026

Copy link
Copy Markdown
Member

HUMAN:


AGENT:

Why

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 assigned over the state loaded from base_state.json — so meta.json silently won, and base_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

  • SDK: extract ConversationConfig (everything except the agent) as a shared base. StartConversationRequest adds the agent; StoredConversation now extends the agent-less ConversationConfig, so the agent cannot appear in meta.json by construction. Breaking: StoredConversation no longer carries agent.
  • 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.
  • agent-server: EventService takes the new-conversation agent separately and, on resume, loads it from base_state.json. switch_acp_model no longer mirrors into meta.json; credential scrub, codex detection, and telemetry read the agent from base_state / the live conversation.
  • Bumped packages 1.41.0 → 1.42.0 for the breaking change.

Issue Number

No upstream issue; reported in the OpenHands Slack #general thread (docker-vs-native / model-switch report). Tracked internally.

How to Test

Ran locally against this branch:

uv run pytest tests/agent_server -q
# => 1980 passed

uv run pytest tests/sdk/conversation/test_base_state_single_source.py -q
# => 3 passed  (base_state-authoritative resume; explicit-agent override; new-conv requires agent)

uv run pytest "tests/agent_server/test_conversation_service.py::test_meta_json_has_no_agent_and_reload_uses_base_state" -q
# => 1 passed  (meta.json has no agent; a fresh ConversationService reloads the agent from base_state.json)

make test-server-schema   # OpenAPI valid + type-quality check passed

ruff and pyright are clean; the repo pre-commit hooks pass.

Compatibility: old meta.json files that still contain an agent key 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

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

Notes

⚠️ The ACP / Codex-subscription persistence paths (switch_acp_model, credential scrub, codex detection) are re-homed and covered at unit level only — they were not exercised against a live ACP/Codex session. Please give those a live check before merge.

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

…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>
@all-hands-bot

Copy link
Copy Markdown
Collaborator

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

enyst and others added 2 commits August 9, 2026 23:26
…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>
@enyst

enyst commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Superseded by #4440, opened from a branch on this repo (not the fork). Same commits.

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.

2 participants