OpenHands agent-server · architecture study

Two files, one truth.

When you run a conversation through the agent-server, the whole agent — its LLM, its condenser, its tools — gets written to disk twice: once in base_state.json and again in meta.json. Two copies of the same thing eventually disagree. That disagreement already quietly reset a saved conversation's settings, and it made the trajectory key-leak twice as bad. Here's exactly where the copy is made, and two ways to end up with a single source of truth.

Grounded to software-agent-sdk @ 01813312 · agent-canvas thread on PR #4028 · §12 build plan cross-checked against #4201/#4100/#4202 and the ACP field durability in switch_acp_model · by smolpaws 🐾

1The short version

the problem The agent is saved twice

The same agent config sits in two files: base_state.json (written by the SDK) and meta.json (written by the agent-server). Nothing keeps the two copies in step.

what went wrong Settings snapped back

Change the LLM mid-conversation and only one copy gets updated. Reopen the conversation later and it can read the stale copy — so a setting like timeout=600 quietly reverted.

the goal One place to look

Make one file the truth. Either shrink meta.json so only the SDK file holds the agent, or merge into a single file the SDK owns and the server extends.

Nothing here needs a brand-new format. Both files already exist and both already hold the agent. The fix is to stop storing it in two places. Below: where the second copy is made, how it bit us, and the two clean ways out — with the trade-offs of each stated plainly.

2The two files, and who writes them

Every persisted conversation directory contains both files. They are written by different layers, on different schedules, with different serializers.

FileOwner / layerWritten byCarries the agent?
base_state.json SDK — ConversationState state.py:421 _save_base_statemodel_dump_json at L437 YesConversationState.agent: AgentBase, plus events HEAD, stats, tags.
meta.json agent-server — StoredConversation event_service.py:169 save_metaself.stored.model_dump_json at L173 YesStoredConversation.agent: AgentBase (models.py:324), plus genuinely server-owned fields.

The problem is the overlap. meta.json has a legitimate job — id, title, created_at/updated_at, fork provenance, the launched agent-profile snapshot (models.py:78, StoredConversation). But it inherits agent: AgentBase from its base class StartConversationRequest, so the full agent blob rides along and becomes a second, independently-written copy of what base_state.json already owns.

SDK layer ConversationState ._save_base_state() agent-server layer EventService.save_meta() on StoredConversation base_state.json events HEAD · stats · tags agent (full) meta.json id · title · timestamps · fork · profile agent (full) ▲ same fact, two writers ▼
Two layers persist into the same conversation directory. The red blocks are the same agent: AgentBase serialized twice, with no synchronization between the writers.

3Where the copy is actually made

The meta.json agent originates from the request that started the conversation and is then re-serialized on every save_meta(). On start, the server even rebuilds the live agent from the stored copy:

# event_service.py — reconstruct the runtime agent from the STORED (meta) copy
agent_cls = type(self.stored.agent)
agent = agent_cls.model_validate(
    self.stored.agent.model_dump(context={"expose_secrets": True}),
)
# … then hand that agent to the SDK, which ALSO persists it into base_state.json
conversation = LocalConversation(agent=agent, …)

event_service.py:974–976 · L1037 LocalConversation(...)

Now watch the two writers diverge. A runtime LLM switch mutates the SDK-side agent — which auto-persists to base_state.json — but the server-side StoredConversation.agent in meta.json is only re-written on its own save_meta() triggers:

# local_conversation.py — switch_llm mutates the SDK agent (→ base_state.json)
def switch_llm(self, llm: LLM) -> None:
    …
    self.agent = self.agent.model_copy(update=update)  # new llm + condenser
    self._state.agent = self.agent                     # autosave → base_state.json

local_conversation.py:1522 switch_llm · endpoint conversation_router.py:492

The asymmetry is the bug. switch_llm updates the SDK agent and base_state.json. It does not call save_meta(). So after a switch, base_state.json and meta.json hold different agent configs — and which one wins depends entirely on which file the next cold start reads.

4How it broke

This is the PR #4028 / issue #4032 report, reduced to the mechanism. A user set an LLM profile timeout, and after an agent-server restart the conversation ran with a different timeout — one that wasn't in any profile.

  1. Start with a profileThe agent's LLM has timeout=600. Both files agree.
  2. Switch the LLM while the conversation is runningswitch_llm installs a new LLM and saves it to base_state.json. It does not re-save meta.json. Now the two files disagree — base_state.json has the new timeout, meta.json still has the old one.
  3. Restart, then touch the conversationHere's the subtle part. Right after a restart, reading the conversation is answered from base_state.json — the correct value, so it looks fine. But the first time anything actually uses the conversation, the server rebuilds the agent from meta.json (the stale copy) and overwrites base_state.json with it.
  4. The timeout revertsThe agent now runs with a timeout the user never chose — silently, because the stale copy won at the moment of use.

Why the read “lies.” A cold read right after restart shows the right value (from base_state.json); the revert only happens on first use, when the agent is rebuilt from meta.json. So checking by hand looks like a pass unless you use the conversation first. That cold-vs-warm split is exactly why the bug was so slippery to pin down. (See PR #4028 §4.)

The report that looked contradictory

The reporter said the timeout is lost on plain restart, with no switch — while the maintainer could only reproduce it with a switch. Both are right, and here's the reconciliation:

  • Editing a profile does not reach a running conversation. The LLM config is frozen into the agent when the conversation is created. So to push a new timeout into an existing conversation, the reporter toggled profiles A→B→A — which he thought of as “fixing the setting,” but mechanically that toggle is the switch.
  • That switch plants the base_state.jsonmeta.json divergence, and the restart then reverts it. Same bug, described from two ends.

A conversation that is never switched keeps its timeout across restarts — the two files stay identical, so it doesn't matter which one wins. There's also a separate, deeper question hiding here: should restoring a conversation re-read its active profile at all? Today it never does; the config is frozen at creation. That's a design choice worth its own discussion, independent of the duplication — taken up and answered in §11 (short version: yes in intent, but the correct shape is a revision-keyed 3-way merge, not a blind re-read).

5Why it's like this (verified from git)

Engel's instinct in-thread — “I don't know why we are doing that or since when” — is right to be surprised, because it was never a deliberate decision in this repo.

WhenWhatEffect on the two files
PR #41 (2025-09-10)“Serialization of Conversation to FileStore”Introduced base_state.json; ConversationState.agent persisted there from day one. This is the legitimate single source of truth.
PR #317 (2025-09-17)“Ported over agent server code”First appearance of the agent-server here. Its first save_meta() already dumped the whole StoredConversation, and StoredConversation extended a request type carrying the full agent — so meta.json was heavy from its first commit in this repo.
PR #356 (2025-09-22)“Unify tool initialization”Changed the embedded field to a full agent: AgentBase, making meta.json's agent structurally identical to base_state.json's — the exact duplication.

Caveat, stated honestly: the agent-server was ported in at #317. The decision that first made meta.json heavyweight lived in the pre-port agent-server codebase, which is not in this repo's history. So there is no single “this PR did it” commit here — the duplication was inherited, then cemented by #356. The lightweight, list-view-only meta.json some remember belonged to the original server and did not survive the port.

6The secret-leak connection

The same duplication doubled the blast radius of a separate security bug. The GET /api/file/download-trajectory/{id} endpoint zips the raw conversation directory. Because the full agent — including the condenser's own LLM — is serialized into both base_state.json and meta.json, a downloaded trajectory carried the LLM API key and the condenser LLM API key, in two files, as plaintext (no OH_SECRET_KEY) or recoverable Fernet ciphertext (with one).

That was fixed at the export boundary in software-agent-sdk #4217 (redact secrets while building the zip) — but that is a bandaid over the duplication. If the agent config lived in one place and, better still, held credential references only (OpenHands #15298), there would be nothing to redact and nothing to keep in sync.

Two bugs, one root cause. Reliability (config reverts) and confidentiality (key leak) are both downstream of “the agent is written twice.”

7Two ways to fix it

Both fixes reach the same goal — one place that owns the agent — and both keep the SDK working the same way when you run it on its own, without the agent-server. They differ in how many files end up on disk.

Option A Keep two files, remove the overlap

Stop writing the agent into meta.json. base_state.json becomes the only home for the agent; meta.json keeps just the handful of things the server owns.

Option B Use one file, extended

Keep only base_state.json. The agent-server tucks its extra fields into one clearly-labelled corner of that same file. Discussed in the next section.

either way The agent is written once

After the switch, reopening the conversation reads the config the user actually chose. No second copy to fall out of step.

Option A — slim down meta.json

Make base_state.json the only owner of the agent, and cut meta.json down to the few things the server genuinely needs to answer “what conversations exist?” without opening the big state file.

before

meta.json today

{
  "id": "…", "title": "…",
  "created_at": "…","updated_at":"…",
  "forked_from_…": "…",
  "launched_agent_profile": {…},
  // everything below is a
  // copy of base_state.json:
  "agent": {
    "llm": { "model":"…",
             "api_key":"…", … },
    "condenser": { "llm": {
             "api_key":"…", … } },
    "tools": [ … ],
    "agent_context": { … }
  }
}
after

meta.json, server fields only

{
  "id": "…", "title": "…",
  "created_at": "…","updated_at":"…",
  "forked_from_conversation_id": "…",
  "forked_from_event_id": "…",
  "launched_agent_profile": {…},
  "metrics": {…}
  // the agent now lives only
  // in base_state.json.
  // the conversation list
  // reads these few fields.
}

The steps:

  1. Don't write the agent into meta.jsonLeave it out of the saved StoredConversation, the same way agent_profile_id is already marked exclude=True at models.py:85.
  2. On reopen, read the agent from base_state.jsonUse the SDK's copy instead of self.stored.agent. A brand-new conversation still gets its agent from the start request — it's just saved to one file, not two.
  3. Keep the list view fastListing conversations needs id, title, timestamps, metrics — all still in meta.json. It never needed the agent.
  4. Be kind to old filesExisting meta.json files still carry an agent; readers should just ignore it and trust base_state.json. Rewrite drops it on the next reopen. Never write it to both again.

Why A is the low-risk choice: it changes almost nothing about how files are read for the conversation list, and it matches how the SDK has always worked on its own. It's a subtraction, not a redesign.

8The single-file idea

Engel's question: could we drop meta.json entirely and keep one file — where the SDK writes the normal conversation state, and the agent-server just adds a few of its own fields on top? So running through the SDK alone gives you the common file, and running through the agent-server gives you the same file plus a small server section.

Short answer: yes, it can work — and it's a nice mental model — but only if you get one detail right (who is allowed to write the file) and accept one trade-off (you may still want a tiny separate index for speed). Here's the reasoning, plainly.

The shape that works

Give the agent-server its own clearly-labelled corner of the SDK file — not fields sprinkled next to the SDK's own. Something like:

{
  // ── common: written by the SDK, same as running standalone ──
  "agent": { "llm": {…}, "condenser": {…}, "tools": [ … ] },
  "events HEAD": "…", "stats": {…}, "tags": {…},

  // ── one labelled corner the SDK carries but never reads ──
  "agent_server": {
    "title": "…",
    "created_at": "…", "updated_at": "…",
    "forked_from_conversation_id": "…",
    "launched_agent_profile": {…}
  }
}

Why a single labelled corner, and not loose fields? Because the SDK validates this file when it loads. Pydantic (what the SDK uses) ignores keys it doesn't know when reading — so extra server fields won't crash a standalone SDK load. Good. But it drops those unknown keys when it writes the file back. So if the SDK is the writer and doesn't know about the server's fields, it will quietly erase them on the next save. That's the whole trick, and it points to one firm rule:

agent-server hands its block to the SDK SDK state writer the ONE writer of the file base_state.json (single file) agent + state (SDK owns) "agent_server": {…} carried SDK writes both blocks; treats the server block as opaque pass-through.
The working shape: exactly one writer (the SDK's state save). The server's fields ride along in a labelled block the SDK preserves but never interprets.

The one rule: a single file needs a single writer. Today two layers write on their own schedules — the SDK saves on almost every state change, the server saves on its own events. If both wrote the same file independently, whoever saved last would erase the other's part. So the SDK must own the write and treat the server's block as a sealed envelope it carries but never opens.

Why this doesn't fall out for free today

  1. The SDK would need a “carry this, don't touch it” slotRight now the SDK's state model has no place to stash foreign fields across a save. It would need one explicit opaque field (e.g. extensions) that survives read → write untouched. Without it, Pydantic drops unknown keys and the server's data vanishes on the next save.
  2. The server writes its fields through the SDK, not around itUpdating the title or updated_at would have to go through the SDK's save path (hand the SDK the new block), instead of the server writing its own file. That's a real change to save_meta.
  3. The fast conversation list gets more expensiveThis is the real trade-off. Today the list reader opens the small meta.json to get id/title/time (conversation_service.py:589). With one file, listing 500 conversations means opening 500 large state files (they can be hundreds of KB) just to read a title. You'd want a small separate index or a DB row — which is, quietly, a second file again.

The honest tension. “One file” is clean for correctness — one writer, one truth, nothing to keep in sync. But the reason a separate meta.json exists at all is cheap listing. Fold everything into one file and you either accept slow listing, or you re-introduce a small index and you're back to two things on disk — just with the index now clearly a derived cache you can rebuild, not a second source of truth.

So which is it?

Option A — slim two filesOption B — one file + block
One source of truth?Yes (agent in base_state only)Yes (everything in base_state)
Fixes the revert bug?YesYes
Fast conversation list?Yes — meta stays smallNeeds a separate rebuildable index
Standalone SDK unchanged?YesYes — server block simply absent
New SDK machinery?NoneAn opaque “carry-through” slot + single-writer rule
Effort / riskLowMedium

Recommendation. The two designs are not really rivals — they're the same idea at different depths. Option A is the safe first move: it kills the duplicate today with almost no new machinery, and it leaves meta.json as an honest little index. Option B is the tidier end state if we also treat listing as a rebuildable index rather than a source of truth. A good path is A now, and keep B open as the direction — especially alongside #15298, which already wants a clean split between “small metadata” and “the rest.”

Update — the cost of Option B is now concrete, not hypothetical. #4201 (perf: index conversation execution status for search/count, landed 2026-07-23) deliberately replaced a full-ConversationState load per conversation with a signature-gated raw read of a single enum — its docstring names the exact price: "validating the full ConversationState costs an agent, LLM config, workspace, secret registry and stats blob to reach one enum field." With #4100 (lazy hydration) and #4202 (idle eviction) alongside it, the codebase is actively investing in less state loading, not more. So the "cheap listing" property is not vestigial — it is defended. That settles it: Option A is the destination, not just the first step; a literal single file is off the table, and any future consolidation must keep listing answerable from a small in-memory index without hydrating state.

9The two bandaids shipped

Two PRs now fix the symptom (#4032). Neither removes the duplication — both keep the agent in both files and just fight the divergence. Worth being honest about that, and about a consistency cost one of them introduces.

#4028 (mirror on write)#4219 (read from base_state)
ApproachWhen the live LLM/condenser changes, copy it into meta.json (a new listener on the agent-change event).On resume, read the LLM/condenser from base_state.json instead of the meta.json snapshot.
Fixes the timeout revert?YesYes
Removes the duplication?No — agent still in both filesNo — agent still in both files
Mirrors neededAdds a second write mirror (alongside the ACP one)None on write
Consistency across agent kindsUniform: meta.json stays authoritative for both regular and ACP agents (mirror-on-change for each).Split brain: regular agents become base_state-authoritative for llm/condenser, but ACP still resumes from meta.json and mirrors acp_model on write. Two philosophies in one file pair.

The honest critique of my own PR (#4219): it fixes the regular-agent bug with no write mirror, but it makes the resume path inconsistent — regular agents read llm/condenser from base_state.json while ACP agents keep reading from meta.json. #4028 at least keeps a single rule for both (meta authoritative, mirror on change). So #4028 is the more internally consistent bandaid; #4219 is the one that leans toward the real fix but only for half the agent kinds. Neither is the destination.

Both are fine as stop-the-bleeding patches. But every mirror or per-kind special case is a tax the duplication keeps charging. The real fix deletes the tax.

11What to watch

who wins at loadCold read vs. hydration

A cold read is served from base_state.json; first use rebuilds the agent and overwrites it. The real fix makes base_state authoritative for all agent kinds, not just regular ones.

ACP & fork pathsEvery reader of stored.agent

ACP model switch (event_service.py:1636), fork, and codex-detection read the meta agent. All must move onto the discriminator + base_state, or the split is only half done.

the deeper fixReferences, not values

Even one copy of the agent still embeds credentials. #15298's reference-only persistence is the endgame; making base_state the sole owner is the step that gets there.

The compact test for any real fix: after an LLM switch and after an ACP model switch, a cold resume must observe the switched config — with no field living authoritatively in two files.

Two open questions, now resolved

Q1 — Is ACP durable in base_state? Yes, fully. acp_model and acp_server are regular Fields on ACPAgent (not PrivateAttr), so they already ride inside state.agentbase_state.json. And switch_acp_model already does the whole job on the SDK side: it sets self._state.agent = new_agent (via model_copy, precisely so the autosave fires) and updates agent_state["acp_current_model_id"] — both persisted. So the agent-server's ACP mirror (event_service.py:1636) is pure redundancy: it exists only because start() rebuilds the agent from stored.agent. Kill the rebuild and the mirror has no reason to exist — it can be deleted in the same step, no one-release grace period. ACP comes under the read-side rule immediately.

Q2 — Should a restore re-read the changed profile? Yes in intent, but "re-read" as stated would clobber runtime — the right shape is a revision-keyed 3-way merge, not a re-read. The reporter's mental model — "edit the profile → my conversation picks it up" — is reasonable: config already changes at runtime, so why not at restore? The catch is that a blind re-read reintroduces #4032 pointed the other way. Runtime switch_llm writes base_state.json only and never flows back into the profile; so create on profile P → switch model at runtime → edit P → restart would let a re-read overwrite the runtime switch. You cannot make both the profile and the live state authoritative for the same field.

So "profiles as the source of truth on restore" really means a 3-way merge keyed on the revision counter: baseline = profile at launch revision, profile_now = current revision, runtime = base_state. Per field — if the profile changed it and runtime didn't, take the profile; if runtime changed it, runtime wins (or prompt on a true conflict). Today profile→agent resolution (resolve_agent_profilecreate_agent) runs at create only (conversation_service.py:343); resume never consults a profile, and agent_settings is documented as the creation-time-only contract.

The good news: the hard hooks already exist, so this is far from a rewrite. LaunchedAgentProfile{agent_profile_id, revision} is already stored and projected onto ConversationInfo precisely so the client's deriveSwitchPlan can tell "which profile is current" without fragile settings-comparison (#3720); AgentProfile.revision is a monotonic counter bumped on each saved edit (so "changed since launch" is one integer compare); and POST /{id}/switch_profile already implements the apply half. Three tiers: (1) re-read + clobber — small but wrong; (2) detect-and-surface a "re-apply" action — nearly free, already wired, safe; (3) correct auto-merge — moderate, and its only missing piece is a launch-time profile snapshot to diff against (we persist the revision, not the old content). Ship tier 2 now; decide tier 3 in writing. Either way this is orthogonal to the duplication fix — a separate PR that must not block Phase 0/1, and must not become an emergent property of which file wins.

12The build plan: burn the overlap down under a guard test

Turning "one source of truth" from a claim in a PR description into a property CI enforces. The order matters: land the scoreboard first, then let each phase shrink it.

Deeper companion: One source of truth — the plan derives the field partition below from set arithmetic over the Pydantic models, traces the two Phase-1 traps to the line, and works through the one ConversationState.create() change everything else rides on.

The test that defines "done"

Two tests. The first proves the behaviour; the second holds the line so no future PR can quietly re-add a duplicate.

(a) Round-trip, parametrized over every mutable field — create a conversation, mutate the field at runtime through its real endpoint, tear the service down, start a fresh one over the same directory, hydrate through the real resume path, and assert the live conversation observes the mutated value.

(b) The structural guard — the rule as an assertion over the type system's own metadata, so it needs no bug report to fire:

# fails the moment anyone re-adds a duplicated field
stored_persisted = StoredConversation.model_fields.keys() - EXCLUDED
overlap = stored_persisted & ConversationState.model_fields.keys()
assert overlap == {"id"}, f"meta.json re-persists ConversationState fields: {overlap - {'id'}}"

Land (b) first, xfail-ed with the current 9-field overlap listed, and let each phase shrink the expected set. Note EXCLUDED must subtract exclude=True fields — exclude=True suppresses serialization, not validation, so an excluded field is still readable in memory during the migration window and would otherwise let the guard pass while the field is still live.

Sequencing

PhaseWhatBlocks
5bStructural guard test, xfail-ed at 9. Land first — it scores the rest.nothing
0Read-side authority, extended to ACP; delete the ACP mirror (Q1 unblocks this). Supersedes #4028 and #4219 — they should not merge alongside it.nothing
1agent out of meta.json (bucket 1: 1 of 9). Needs the ConversationState.create(agent=None) resume-path change.needs 0
2Rest of bucket 1 (8 of 9) — same create() generalisation makes it mechanical.needs 1
2bReconcile secrets vs secret_registry (gated on the credential model, #15298).needs 1
4Index relabel / Option Bcut — #4201 already built the index

The Phase 1 traps (both easy to miss, both pass on old fixtures and break later): (1) StoredConversation.agent is typed AgentBase but defaults to None — a new-style meta.json validates fine and every remaining unguarded type(stored.agent) then fails with a confusing NoneType error, so branch explicitly on the resume path rather than leaning on the default. (2) The one real SDK change is making ConversationState.create() take agent: AgentBase | None (None on resume → keep the persisted agent, skip the overwrite and verify) — this is the same change bucket-1's workspace/max_iterations need, so do it once, generalised.

Guardrail for every phase: do not regress #4201 / #4100 / #4202 — sort, filter, count and paginate must stay answerable from the in-memory catalog without loading ConversationState. And agent-canvas needs no changes: the HTTP API already builds its conversation response from ConversationState, not from the meta snapshot, so removing agent from meta.json changes zero bytes of any API response. Both are worth an explicit line in each PR description, because "will this break the client / the list view?" is the question that otherwise stalls review.

13Sources