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.
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.
1The short version
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.
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.
| File | Owner / layer | Written by | Carries the agent? |
|---|---|---|---|
base_state.json |
SDK — ConversationState |
state.py:421 _save_base_state → model_dump_json at L437 |
Yes — ConversationState.agent: AgentBase, plus events HEAD, stats, tags. |
meta.json |
agent-server — StoredConversation |
event_service.py:169 save_meta → self.stored.model_dump_json at L173 |
Yes — StoredConversation.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.
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, …)
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
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.
- Start with a profileThe agent's LLM has
timeout=600. Both files agree. - Switch the LLM while the conversation is running
switch_llminstalls a new LLM and saves it tobase_state.json. It does not re-savemeta.json. Now the two files disagree —base_state.jsonhas the new timeout,meta.jsonstill has the old one. - 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 frommeta.json(the stale copy) and overwritesbase_state.jsonwith it. - 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.
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.json⁄meta.jsondivergence, 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.
| When | What | Effect 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.
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.
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.
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.
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": { … }
}
}
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:
- Don't write the agent into
meta.jsonLeave it out of the savedStoredConversation, the same wayagent_profile_idis already markedexclude=Trueat models.py:85. - On reopen, read the agent from
base_state.jsonUse the SDK's copy instead ofself.stored.agent. A brand-new conversation still gets its agent from the start request — it's just saved to one file, not two. - Keep the list view fastListing conversations needs id, title, timestamps, metrics — all still in
meta.json. It never needed the agent. - Be kind to old filesExisting
meta.jsonfiles still carry an agent; readers should just ignore it and trustbase_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:
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
- 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. - The server writes its fields through the SDK, not around itUpdating the title or
updated_atwould 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. - The fast conversation list gets more expensiveThis is the real trade-off. Today the list reader opens the small
meta.jsonto 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 files | Option B — one file + block | |
|---|---|---|
| One source of truth? | Yes (agent in base_state only) | Yes (everything in base_state) |
| Fixes the revert bug? | Yes | Yes |
| Fast conversation list? | Yes — meta stays small | Needs a separate rebuildable index |
| Standalone SDK unchanged? | Yes | Yes — server block simply absent |
| New SDK machinery? | None | An opaque “carry-through” slot + single-writer rule |
| Effort / risk | Low | Medium |
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) | |
|---|---|---|
| Approach | When 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? | Yes | Yes |
| Removes the duplication? | No — agent still in both files | No — agent still in both files |
| Mirrors needed | Adds a second write mirror (alongside the ACP one) | None on write |
| Consistency across agent kinds | Uniform: 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.
10Doing it right: two objects, not two files' worth of overlap
Step back from the field lists and there are really only two things worth persisting, plus a little server bookkeeping:
llm, condenser, tools, agent_context, mcp_config (or acp_model). It's a field of the Conversation.
ConversationState: the agent + events HEAD + stats + secret registry + run config. The SDK owns it and writes it to base_state.json.
A handful of fields the agent-server adds for its list/lifecycle: timestamps, title, metrics, fork lineage, launched profile.
So the target is simple to state: base_state.json is the one persisted Conversation (Agent included). meta.json holds only what the server genuinely adds, plus whatever create-time inputs the SDK consumes at construction but does not round-trip into state.
What meta.json is today (measured, not eyeballed)
StoredConversation is literally the server's create-request re-persisted: it subclasses StartConversationRequest and adds a few fields. Comparing the classes directly gives an exact partition — no hand-labeling.
Two clarifications worth stating plainly, because they're easy to get wrong: (1) StartConversationRequest is effectively a server/API contract, not something the pure SDK consumes — the SDK's Conversation/LocalConversation constructors take plain args (agent=, workspace=…), never a request object; it only lives in the SDK package so client and server share one schema. (2) ConversationState is the real SDK persistence object. So "meta = persisted request" and "base_state = persisted Conversation".
Bucket 1 — Duplicates the Conversation (the bug surface): 8 fields
In both StoredConversation and ConversationState today. base_state.json already owns these; meta re-storing them is the duplication.
| Field | Note |
|---|---|
agent | The whole agent. The headline case (#4032). |
workspace | Working directory. |
max_iterations, stuck_detection | Run caps / loop guard. |
confirmation_policy, security_analyzer | Run-time action policy. |
hook_config | Lifecycle hook scripts. |
tags | Labels (also an index field). |
id | Shared key — harmless, but technically in both. |
Bucket 2 — Create-request inputs the SDK consumes but doesn't round-trip into state
These are inherited from StartConversationRequest and are not ConversationState fields. The SDK transforms them at construction (plugins → merged into the agent; secrets → secret_registry; observability → telemetry) and doesn't keep the raw input in state. So the server legitimately keeps them to reconstruct the conversation on restart.
| Field | Meaning |
|---|---|
plugins | Plugins to fetch/merge into the agent at start. |
secrets, secrets_encrypted | Secret sources (+ encrypted-input flag). Become secret_registry in state — related, not identical; one representation to reconcile. |
client_tools, tool_module_qualnames, agent_definitions | Client/registry wiring: client-executed tool specs, tool modules to import, subagents to register. |
agent_settings, agent_profile_id | SDK alternatives to a concrete agent at create time (validated / resolved into one). SDK concepts, not server-only. |
agent_launch_additions | Deployment context layered on after profile resolution. |
parent_conversation_id | Owning conversation (SDK request field). |
worktree | Create a dedicated git worktree. |
autotitle, title_llm_profile | Titling behavior + the profile to title with. |
observability_metadata / _tags / _span_name, user_id | Tracing/correlation metadata. |
initial_message, conversation_id | The opening message; the requested id. |
Bucket 3 — Genuinely added by the agent-server: 9 fields
These are exactly StoredConversation − StartConversationRequest — the only fields the server truly owns.
| Field | Meaning |
|---|---|
id | Conversation id (server-assigned form; also the shared key above). |
title | User-facing name for the list. |
created_at, updated_at | Lifecycle timestamps. |
metrics | A MetricsSnapshot for the list without loading stats. |
forked_from_conversation_id, forked_from_event_id | Fork lineage. Note: fork is an SDK operation (LocalConversation.fork), but the SDK doesn't record "who I came from" — the server does. |
launched_agent_profile | Provenance snapshot of the profile that launched it. |
required_runtime_credential_bindings | Credential bindings (e.g. Codex) to resolve before the runtime starts. |
The concrete moves
- Delete bucket 1 from
meta.jsonStop persisting agent, workspace, max_iterations, stuck_detection, confirmation_policy, security_analyzer, hook_config, tags into meta.base_state.jsonalready owns them; on resume, read them from there. - Add a tiny agent discriminator to metaThe one thing the server needs cold (before loading state) is the agent kind — today it reaches for the whole
stored.agent(e.g._is_codex_agent). Replace withagent_kind("Agent"|"ACPAgent") and, for ACP,acp_server. Small, stable, not the agent. - Reconcile the two secret shapesmeta's
secrets(sources) vs base_state'ssecret_registry(resolved). Describe a credential in exactly one place — the direction #15298 wants anyway. - Keep listing cheapThe list view reads bucket 3 (+ maybe bucket 2) from the small meta. If you ever fold meta into base_state (the single-file option), keep a rebuildable index instead — a derived cache, not a second source of truth.
With bucket 1 gone, both bandaids evaporate: nothing to mirror, no per-kind resume rule, because the agent (and the rest of the Conversation) has exactly one home. The rule to hold the line, in full: if a field is on ConversationState, it must not be authoritative in meta.json. If the list view needs it cold, it may exist there only as a signature-gated derived copy — one-way, rebuildable, memory-wins-when-live, never read back into the conversation.
That second clause isn't a loophole — it's the shape that already exists and works. execution_status is a ConversationState-only field the server caches in _ConversationRecord and refreshes through _refresh_execution_statuses(): gated on an os.stat signature, invalidated to None the instant the conversation goes live ("went live while we were reading disk; memory wins"). Any derived index copy must have that shape — and it must live on _ConversationRecord, not on StoredConversation, so the structural guard (below) still fires on real duplication.
11What to watch
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.
stored.agentACP 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.
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.agent → base_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_profile → create_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
| Phase | What | Blocks |
|---|---|---|
| 5b | Structural guard test, xfail-ed at 9. Land first — it scores the rest. | nothing |
| 0 | Read-side authority, extended to ACP; delete the ACP mirror (Q1 unblocks this). Supersedes #4028 and #4219 — they should not merge alongside it. | nothing |
| 1 | agent out of meta.json (bucket 1: 1 of 9). Needs the ConversationState.create(agent=None) resume-path change. | needs 0 |
| 2 | Rest of bucket 1 (8 of 9) — same create() generalisation makes it mechanical. | needs 1 |
| 2b | Reconcile secrets vs secret_registry (gated on the credential model, #15298). | needs 1 |
| 4 | Index relabel / Option B | cut — #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
- state.py:421 —
_save_base_statebase_state.json is the SDK's single source of truth; persistsConversationState.agent. - event_service.py:169 —
save_metaWrites the fullStoredConversation(incl. agent) into meta.json. - models.py:78 —
StoredConversationExtendsStartConversationRequest; inheritsagent: AgentBase(L324). - event_service.py:974 — agent rebuilt from
stored.agentStart path reconstructs the runtime agent from the meta copy. - local_conversation.py:1522 —
switch_llmMutates SDK agent → base_state.json only; does not save_meta. - issue #4032The bug report: LLM profile timeout reset after agent-server restart.
- PR #4028Bandaid 1 — mirror the switched llm/condenser into meta.json on change.
- PR #4219Bandaid 2 — read llm/condenser from base_state.json on resume (regular agents only).
- PR #4201perf — index conversation execution status for search/count; replaces a full-state load with a signature-gated raw enum read. The concrete cost of loading state for listing.
- PR #4100 / PR #4202Lazy hydration + idle eviction — the codebase actively investing in less state loading, which is why Option B's index cost is real.
- PR #4217 / issue #4216Trajectory secret-leak fix (export-boundary redaction) — a related bandaid.
- OpenHands #15298Reference-only credentials — the endgame that makes even one copy safe.