Re: <https://github.com/OpenHands/software-agent-s...
# proj-agent
c
Re: https://github.com/OpenHands/software-agent-sdk/pull/4028 cc @modern-fireman-99755 My understanding from the initial report is that the bug didn’t seem to have anything to do with LLM Switch, no matter what path. I think the use scenario was: • make a profile with timeout=600 • talk to the agent etc • restore the conversation later • expected: timeout is 600 • actual: timeout reverted to something (default LLM value?)
👀 1
So I’m sorry, it must be me, I’m still confused why are solving for an LLM Switch. Please do tell what I’m missing. Does that scenario just work correctly today? Aside from this, I would like to note that I think maybe it happens on LLM Switch too (from the code), and the fix seems to address that…
m
I double checked. We can reproduce the issue just if we switch the LLM somewhere. It is impossible to reproduce without the switch. I agree that in the issue we don't speak about a switch, but if I remember correctly @better-monitor-61654 mentioned that in the slack discussion that we had. Could you confirm? Were you switching the LLM middle convo or at the start of the convo (before restarting?) Or you restarted a convo with another LLM? (which is a switch)
🤔 1
Or perhaps, you have the same LLM but you changed something in the LLM profile in the meantime?
c
I am also relying on this thread https://openhands-ai.slack.com/archives/C06P5NCGSFP/p1783791409275459 My reading is that Christopher may have switched occasionally to fix it, because it wasn’t restoring the desired timeout otherwise 🤔
m
Should I push the reproduction script with and withouth switch in the .pr in PR such that you can also give a look?
c
I'd love that!
m
Ok give me a sec
@calm-artist-7206 ok done
you can give a look
c
🤔 we are saving the full agent/llm information in both base_state.json and meta.json, I don’t know why we are doing that or since when
(sorry, just a quick aside, because I’m surprised)
m
mmmh good question... when I saw the issue I thought that we renamed base_state.json to meta.json but i didn't pay attention to that detail
@nutritious-balloon-65098 Why are we saving the full agent/llm information both in base_state.json and meta.json? Where are these two json files used? Can we not just use one of them?
n
I'm on it! Vasco can track my progress here.
OpenHands encountered an error: **Failed to fetch final response from agent server: HTTP 503 error: no available server ** See the conversation for more information.
c
it probably responded to you in the conversation as a message
m
This post was generated by Vasco in behalf of OpenHands --- I now have a complete picture. Here's the answer to your questions. Why the full agent/llm is in both files The two files are owned by two different layers of the stack, and each was designed to be self-sufficient for that layer:
base_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:
Copy code
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.
c
No, the way we used to do this was: • base json is source of truth for one conversation • meta.json contains only a few data that are useful in the list of conversations (the multiple convos running )
the data in meta.json was not in base_state too
Can we ask your silent agent since when, what PR, did we start saving the full convo state in meta.json too?
m
yes a moment
c
I think the cleanest way is to unify these, by making the specific meta fields optional parts of base_state.json; but if that is too involving, maybe just restore it as it used to be: a single source of truth for any item, where meta should only contain a few server-specific fields, and the rest is convo state
m
Here's what the history shows. ## Short answer In this repo, the agent-server's
meta.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:
Copy code
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?
n
OpenHands encountered an error: **Failed to fetch final response from agent server: HTTP 503 error: no available server ** See the conversation for more information.
c
wow if that’s accurate it’s pretty concerning… I mean, if I'm reading this right,this very bug is due to the duplicate source of truth
m
yes this is true... and the bug fired just now because we have the switch tool
c
any chance we could kill the use of meta.json all across agent-server, or have something, a HTML design doc page is what I do lately but anything you’d like, to help us understand what’s here and how it should be?
m
So I don't know... it seems very risky... but should be possible
b
@modern-fireman-99755 the issue occurs when starting agent-server (before I have the chance to change the LLM profile). In the github issue I explained how to reproduce. Without your PR many of my conversations start broken, with old wrong LLM settings that are stored somewhere (not in a LLM profile). Without your PR, I can only recover the conversation by forcing a LLM profile update (changing from profile A to B to A). Here I answered more questions: https://openhands-ai.slack.com/archives/C06P5NCGSFP/p1783791409275459
b
🐾 Wrote up the duplicate-source-of-truth thing as a short design doc — where the agent gets serialized into both
base_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.html
🐾 Hi — I'm smolpaws, Engel's agent; I dug through both threads, issue #4032 and Vasco's PR evidence. I think you're both right and describing the same bug from two ends: • Editing a profile doesn't reach an already-running conversation — the LLM config is frozen in at creation. So to push the new timeout in, you toggle profiles A→B→A. That toggle is a
switch_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.html
c
Fun detail
b
🐾 Ran it myself to settle the confusion, on both Vasco's PR branch and released
main
(
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.
🐾 Opened #4219 as a structural alternative to this PR — not to override it, just to put both options in front of you. Same bug, different level: #4028 mirrors the switched `llm`/`condenser` into
meta.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/4219
m
the things which is strange is that I cannot reproduce what you are describing in your issue. I can just reproduce if I switch somewhere the profile. @better-monitor-61654
b
After you restart the container, it uses the timeout you set in the profile before restarting the container? Maybe an additional step is required before replicating the issue to mess up a new session, like switching and deleting an old profile, because as I mentioned, when agent-server restarts, it uses an old timeout value probably from an old deleted profile. If you detected and solved another related issue, maybe it fixes mine too.
m
ike switching and deleting an old profile
Exact, this is the step i was adding to replicate the problem