OpenHands agent-server · design decision

Three ways to end the split.

Two earlier notes proved the agent-server writes the whole agent to disk twice and planned the single-owner end state. This is the decision note: three concrete ways to actually get there — (a) exclude the agent from the stored record, (b) a minimal "base_state wins" patch, (c) stop the stored record from being a create-request at all — and why (c) wins.

Grounded to software-agent-sdk @ upstream/main be6cd3b80 · companions: the duplication study · the single-source plan · 🐾

1The short version

option a Exclude the agent

Keep StoredConversation(StartConversationRequest), mark agent as exclude=True. Correct end state, but you fight the class's own shape — every new request field silently leaks back into meta.json unless someone remembers to exclude it.

option b base_state wins

Leave both files carrying the agent; just make resume prefer base_state.json and make switch_llm persist. Smallest blast radius, fixes Neal's symptom — but the ~65 KB duplication and the footgun stay.

option c ✓ Break the inheritance

Stop StoredConversation from being a StartConversationRequest. The request carries the agent; the stored record lists only what it truly owns. The agent has nowhere to live but base_state.json — by construction.

All three reach the same destination the single-source plan named: base_state.json is the one owner of the agent. They differ in how honestly the type system enforces it. (a) enforces by discipline, (b) doesn't enforce at all, (c) makes the duplication structurally impossible.

2The split, in one paragraph

A conversation's agent — its LLM, condenser, and tools — is persisted in two files. base_state.json is ConversationState, which already owns the full runtime state (agent, workspace, confirmation policy, security analyzer, iteration cap, stats, tags). meta.json is StoredConversation, which extends StartConversationRequest and therefore re-persists the whole create request, agent included. On resume, EventService.start() rebuilds the agent from meta.json and hands it to ConversationState.create(), which does state.agent = agent — so the meta.json copy wins, and base_state.json's agent is used only to check the tools still match. Write a model switch to one file and not the other, and a reload silently reverts it. That is the class of bug Neal reported.

Root cause in one line: StoredConversation is "the create request, re-persisted." The agent is in meta.json because the request had an agent — not because meta.json needs one.

3Option (a): exclude the agent

Keep the inheritance, add a Pydantic exclude=True to the heavy fields so they don't serialize into meta.json, and flip resume to read the agent from base_state.json.

class StoredConversation(StartConversationRequest):
    # already excluded today:
    agent_profile_id: UUID | None = Field(default=None, exclude=True)
    # (a) would add:
    agent: AgentBase = Field(exclude=True)   # stop persisting into meta.json

Scope: small diff, but it's a diff against the grain. The class still declares it is a create request; you're carving exceptions out of it one field at a time. There's a partial precedent already — agent_profile_id carries a comment: "exclude from the persistence payload so it does not re-appear in meta.json." That comment is the tell: the model's default behavior is to leak, and every field is a landmine. Add a new field to StartConversationRequest next quarter and it silently lands in meta.json unless the author knows this history.

verdict Correct destination, fragile guardrail. Enforced by memory, not by types.

4Option (b): base_state wins

Change nothing structural. Keep agent in both files, but fix the two concrete failure modes: on resume treat base_state.json as authoritative for the agent, and make switch_llm durably rewrite it so an idle-eviction reload can't revert the switch.

  • Resume: don't let the meta.json agent override a newer persisted base_state.json.
  • switch_llm: persist the post-switch agent (and repoint condenser/title LLMs, per the switch_llm footgun we already logged).

Scope: the smallest, safest change; ships Neal's fix fast. But it treats the symptom. The ~65 KB agent duplication remains, two writable copies of the same object remain, and the next code path that writes the wrong file re-opens the same bug. It is explicitly not "really remove the duplicates."

verdict A good stopgap. Not a fix for the design.

5Option (c): break the inheritance

Fix the modeling error itself. StoredConversation stops inheriting from StartConversationRequest. The request still carries the agent on the wire; the stored record becomes a deliberate, explicit list of only what it owns.

# before
class StoredConversation(StartConversationRequest):  # inherits agent, workspace, secrets, ...
    id: OpenHandsUUID
    title: str | None = ...

# after (c)
class StoredConversation(OpenHandsModel):   # owns its fields on purpose
    id: OpenHandsUUID
    title: str | None = ...
    # server-orchestration fields it genuinely needs (see partition)
    plugins: ...; client_tools: ...; secrets: ...
    # NO agent field. The agent lives in ConversationState -> base_state.json.

Then the two lifecycle paths become explicit rather than accidental:

  • New conversation: deserialize the StartConversationRequest; write the list/orchestration fields to meta.json, and the agent into ConversationStatebase_state.json. No agent in meta.json, ever.
  • Restore: load the agent and runtime state from base_state.json; create() keeps state.agent instead of overwriting it. Any runtime change (switch_llm) persists by rewriting base_state.json.

The field re-homing sorts into three buckets, mechanically:

BucketFields (examples)Home under (c)
Already in ConversationStateagent, workspace, confirmation_policy, security_analyzer, max_iterations, stuck_detectionbase_state.json (drop from meta)
Server orchestration, not in stateplugins, client_tools, secrets, agent_definitions, tool_module_qualnames, worktree, autotitleexplicit fields on StoredConversationmeta.json
Pure list-viewid, title, created_at/updated_at, status, tags, metricsmeta.json

verdict Same destination as (a), but the guardrail is the type itself: with no agent field and no inheritance, the duplication cannot silently return.

6The resume/reattach edge

The sharp question about (c): a client sends a StartConversationRequest whose id already exists, so the server reloads the existing conversation. Doesn't the incoming request's agent then need to override base_state.json?

Read the code, and the answer is no — because the server already ignores the request's agent on that path. _start_conversation() is get-or-create: if a record or open event service exists for the id, it returns the existing conversation (created=False) built from existing_event_service.stored. The only thing it selectively pulls from the incoming request is the Codex credential secret. The request's agent is discarded today.

So (c) doesn't introduce a new hazard here — it matches behavior that already exists. Reconfiguring a live conversation's model is the job of the dedicated switch_llm endpoint, not of re-POSTing the create request. Wiring a "request overrides base_state" branch would actually resurrect the split-brain: a client with a stale agent could clobber a persisted switch.

The clean rule that falls out: start_conversation never overrides the persisted agent; switch_llm is the only mutator, and it persists to base_state.json. One thing to verify while implementing: that no current caller (canvas / app-server) relies on re-sending start_conversation to change a model. The evidence so far says they use switch_llm.

7Why (c)

(a) and (c) end in the same place; (b) doesn't go all the way. The difference between (a) and (c) is where the invariant lives. (a) keeps the class saying "I am a create request" and then spends exclude=True annotations arguing otherwise — a guardrail made of discipline. (c) removes the false is-a: a stored record is not a create request, it just happens to have been born from one. Once that's true, "what lives in meta.json" is an explicit field list a reviewer reads directly, and the agent duplication is not a bug you prevent but a state you can't express.

Cost is honest: (c) touches every reader of stored.agent (codex detection, secret scrubbing, title and condenser LLMs, client-tool injection, the ACP model swap) and needs a switch→reload regression test plus a matching note upstream. That's the price of fixing the model instead of the symptom — and it's the one Engel asked for: really remove the duplicates.

8Sources

  • OpenHands/software-agent-sdkopenhands-agent-server/openhands/agent_server/models.py (StoredConversation), conversation_service.py (_start_conversation, _compose_conversation_info), event_service.py (start), openhands-sdk/openhands/sdk/conversation/state.py (ConversationState.create).
  • Companion notes: the duplication study, the single-source plan.

Written from a code read, not a repro, for the resume/reattach edge. Grounded to upstream/main be6cd3b80. 🐾 smolpaws