calm-artist-7206
07/24/2026, 1:47 PMcalm-artist-7206
07/24/2026, 1:49 PMmodern-fireman-99755
07/24/2026, 2:38 PMmodern-fireman-99755
07/24/2026, 2:39 PMcalm-artist-7206
07/24/2026, 2:42 PMmodern-fireman-99755
07/24/2026, 2:44 PMcalm-artist-7206
07/24/2026, 2:44 PMmodern-fireman-99755
07/24/2026, 2:45 PMmodern-fireman-99755
07/24/2026, 2:54 PMmodern-fireman-99755
07/24/2026, 2:54 PMcalm-artist-7206
07/24/2026, 3:14 PMcalm-artist-7206
07/24/2026, 3:14 PMmodern-fireman-99755
07/24/2026, 3:17 PMmodern-fireman-99755
07/24/2026, 3:17 PMnutritious-balloon-65098
07/24/2026, 3:18 PMnutritious-balloon-65098
07/24/2026, 3:22 PMcalm-artist-7206
07/24/2026, 3:22 PMmodern-fireman-99755
07/24/2026, 3:24 PMbase_state.json — owned by the SDK (openhands-sdk)
• Written by ConversationState._save_base_state() (openhands-sdk/openhands/sdk/conversation/state.py). It's a full model_dump of ConversationState, and agent (with its llm) is a first-class field on that model.
• This is deliberate per the SDK's design principle: agents are immutable Pydantic models, so their configuration is their serialized representation. The SDK persists the agent so a pure-SDK Conversation can resume/verify/replay with no server involved. The field docstring even says it's persisted "to allow resuming conversations and check agent configuration to handle tool changes, LLM changes, etc."
meta.json — owned by the agent-server (openhands-agent-server)
• Written by `EventService.save_meta()`; it serializes StoredConversation, which extends StartConversationRequest and also embeds the full agent.
• The server needs its own file because it stores server-only fields the SDK's ConversationState doesn't have: title, tags, secrets, plugins, workspace, confirmation_policy, launched_agent_profile, credential bindings, agent_profile_id, etc.
Where each is used
• meta.json is read on server startup by ConversationService._load_catalog_sync() to build the conversation catalog without instantiating the SDK (fast listing, cheap). More importantly, on EventService.start() the runtime agent is rebuilt from self.stored.agent (i.e. from meta.json):
• agent_cls = type(self.stored.agent)
• agent = agent_cls.model_validate(self.stored.agent.model_dump(...))
• conversation = LocalConversation(agent=agent, ...)
• base_state.json is read by ConversationState.create() on the SDK resume path.
The catch (this is the crux of the PR #4028 bug)
On the resume path in ConversationState.create(), when base_state.json exists it loads the persisted state and then overwrites the agent with the runtime-provided one:
state = cls.model_validate(json.loads(base_text), ...) # agent from base_state.json
...
state.agent = agent # <-- overwritten with the agent passed by the server
Since the server passes the agent it built from meta.json, meta.json is effectively authoritative in the agent-server context, and `base_state.json`'s persisted agent is loaded-then-discarded. So they're two sources of truth for the same data, and whichever one a "switch"/profile update writes to determines what survives a restart. If a profile change (e.g. timeout=600) updates one file but the resume path reads the other, the value silently reverts — which matches the reported symptom.
Can we use just one?
Not trivially, but the duplication of the full agent is genuinely redundant:
• We can't drop meta.json — the server needs the server-only fields and the cheap catalog listing that doesn't boot the SDK.
• We can't drop base_state.json — the standalone SDK path knows nothing about meta.json.
• But we don't need the full `agent`/`llm` blob in both. Because the server always rebuilds the runtime agent from meta.json (and ConversationState.create overwrites `base_state`'s agent anyway), the agent in base_state.json is effectively write-only in the server context. The clean fix is to pick one owner for the agent config and have the other reference it, rather than serializing two independent copies that can drift.
My recommendation: make meta.json the single source of truth for the agent config on the agent-server (it already wins), and stop persisting a second full copy in base_state.json when running under the server — or, conversely, ensure any switch/profile-update writes both atomically. Either eliminates the drift; the first also removes the redundancy you flagged.
Would you like me to:
1. Trace exactly where a profile/LLM switch writes (does it update meta.json, base_state.json, or both?) and pinpoint the precise divergence, or
2. Draft the actual fix (single-source-of-truth for the agent config) as a code change?
I haven't made any code changes yet — just investigated. Let me know how you'd like to proceed.calm-artist-7206
07/24/2026, 3:27 PMcalm-artist-7206
07/24/2026, 3:27 PMcalm-artist-7206
07/24/2026, 3:28 PMmodern-fireman-99755
07/24/2026, 3:32 PMcalm-artist-7206
07/24/2026, 3:33 PMmodern-fireman-99755
07/24/2026, 3:33 PMmeta.json has embedded the full agent/LLM config since the day the agent-server was ported in — it was never the lightweight, list-only file you're remembering. There is no PR here that "added" full state to `meta.json`; it arrived that way from the port.
## The timeline (verified from git)
1. base_state.json — the SDK source of truth (this is the "old way" you described)
- PR #41 "Serialization of Conversation to FileStore" (2025-09-10) introduced base_state.json, and ConversationState.agent was persisted there from the start — full agent config, per-conversation. This is the SDK's single source of truth and it matches your mental model exactly.
2. meta.json — arrived already-full via the port
- PR #317 "Ported over agent server code" (2025-09-17) is the first appearance of the agent-server in this repo. Its very first save_meta() already did:
python
meta_file.write_text(self.stored.model_dump_json())
and StoredConversation extended StartConversationRequest(AgentSpec), where AgentSpec already carried the full llm, tools, condenser, agent_context, etc. So meta.json was a full serialization from its first commit in this repo.
- PR #356 "Unify tool initialization" (2025-09-22) then changed the embedded field from the AgentSpec creation-spec to a full agent: AgentBase object (StartConversationRequest gained agent: AgentBase). That's when `meta.json`'s agent blob became structurally identical to `base_state.json`'s agent — i.e. the exact duplication you're pointing at.
## The important caveat
The agent-server was "ported over" in #317 — meaning the "full state in meta.json" decision was made in the previous/original agent-server codebase, before this monorepo existed. So the commit that originally made meta.json heavyweight is **not in this repo's history**; #317 just carried it in as-is. In this repo, the closest thing to "when we started saving full agent state in meta.json" is:
- #317 (Sep 17, 2025) — full agent config (as AgentSpec) lands in meta.json via the port, and
- #356 (Sep 22, 2025) — it becomes a full agent: AgentBase, exactly duplicating the base_state.json agent.
So the "meta.json holds only a few list-view fields, and that data isn't in base_state" design you remember was the original/other agent-server, and it was already changed before the port — it did not survive into this SDK monorepo. Nobody in this repo made a deliberate PR to move full state into `meta.json`; it was inherited.
Want me to dig into the pre-port original agent-server repo (if you can point me at it), or draft a proposal to slim meta.json back down to list-view fields and let base_state.json be the sole owner of the agent config?nutritious-balloon-65098
07/24/2026, 3:33 PMcalm-artist-7206
07/24/2026, 3:35 PMmodern-fireman-99755
07/24/2026, 3:35 PMcalm-artist-7206
07/24/2026, 3:38 PMmodern-fireman-99755
07/24/2026, 3:39 PMbetter-monitor-61654
07/24/2026, 5:06 PMbetter-monitor-61654
07/24/2026, 5:07 PMbland-bear-604
07/24/2026, 5:13 PMbase_state.json and meta.json, how that reverted the timeout on restore (only when an LLM switch diverges the two copies), the git history, and two ways to fix it: slim meta.json to server-only fields, or go to one shared file with a small agent-server block on top of the SDK state. Plain-English, code-grounded:
https://enyst.github.io/arch/meta-vs-base-state-duplication.htmlbland-bear-604
07/24/2026, 5:28 PMswitch_llm under the hood — which is the "switch" Vasco needs to repro, even though it doesn't feel like one.
• The switch writes the new timeout to base_state.json but not meta.json. On restart the agent gets rebuilt from meta.json (stale) on first use → revert.
• Why it's slippery: the cold read right after restart is served from base_state.json and looks correct; the revert only bites once the conversation is actually used (PR #4028 §4). So verify by touching it first.
Separate, deeper question worth its own issue: should restoring re-read the active profile at all? Today it never does. Write-up: https://enyst.github.io/arch/meta-vs-base-state-duplication.htmlcalm-artist-7206
07/24/2026, 5:29 PMbland-bear-604
07/24/2026, 5:42 PMmain (01813312). Pushed an independent repro to the PR: .pr/engel_active_profile_question.py (commit 12241acc3) — it dumps the raw `meta.json`/`base_state.json` bytes, restarts the service over the same dir, and reads the live agent timeout.
Works today (600 → 600, on main AND the PR):
• profile timeout=600, made active, start a NEW conversation from it, never switch, restart. Both files hold `"timeout":600`; they never diverge, so the restart reinstates 600. This path was never broken.
• plain agent created with timeout=600; and profile-edited-after-creation (the edit just doesn't reach an existing convo).
Broken without the PR (600 → 300):
• switch the LLM mid-convo (incl. the A→B→A profile toggle). The switch writes to base_state.json but not `meta.json`; on restart the agent is rebuilt from the stale meta.json → reverts to the 300 default. The PR fixes exactly this by mirroring the switch into meta.json.
So: no-switch = fine; switch-not-mirrored = the bug. 300 is the SDK default that surfaces only when a path builds the LLM without carrying the profile's value.bland-bear-604
07/24/2026, 6:25 PMmeta.json on change (correct, minimal). #4219 instead makes base_state.json authoritative on resume — start() reads `llm`/`condenser` from base_state (where switches already persist) and keeps tools/context/mcp from the creation snapshot. No write-side mirror, so it also covers the ACP-switch path (which today has its own hand-rolled save_meta mirror) and any future `llm`/`condenser` divergence for free.
Same field-scoping insight as yours, applied on the read side. Test: create timeout=300 → switch to 600 → real restart → live agent uses 600 (fails pre-fix). 212 tests green.
WDYT? Happy to converge on whichever you prefer — no ego here. https://github.com/OpenHands/software-agent-sdk/pull/4219modern-fireman-99755
07/27/2026, 7:33 AMbetter-monitor-61654
07/29/2026, 9:56 AMmodern-fireman-99755
07/29/2026, 9:56 AMike switching and deleting an old profileExact, this is the step i was adding to replicate the problem