OpenHands SDK 1.33.0 · conversation resume

A resumed conversation looked brand-new — history was orphaned, not lost

A conversation created under agent-server 1.29.3 (a flat event log) was resumed under 1.33.0 (a parent-linked event tree). All 5,814 events were intact on disk, but the agent's LLM context was rebuilt from only the last ~165 — because the first event written after resume was stamped as a hard tree root, cutting the walk off from the 5,566 older events.

1One-sentence answer

The agent rebuilds its context by walking a linked list of events from the newest back to a root, following each event's parent_id. On resume across the 1.29.3→1.33.0 boundary, the first new event got parent_id="__root__" (an explicit "stop here"), so the walk halted there and never reached — or fell back to — the older flat-log events. The fix is a one-field re-link; no data was lost.

Blast radius. This is triggered by resuming a pre-tree conversation whose stored tail is not a bare legacy event (e.g. it ends on a state-update or error event that already carries a parent_id). Anyone upgrading across that boundary and resuming can hit it.

2The symptom

After resume, asked what it remembered, the agent reported only the two most recent user messages — as if the conversation had just begun. Meanwhile the browser UI still showed the full history. That split is the tell: the UI reads the flat directory of event files; the agent reads the tree. They disagreed.

ReaderSource of truthSaw
Browser UIflat list of events/event-*.jsonall 5,814 events ✔
Agent (LLM context)path_to_root(leaf) — tree walk~165 events, 2 user messages ✗

3Two storage models: linear → tree

1.29.3 stored events as a plain sequence: file N follows file N-1, implicitly. 1.33.0 kept the same files but added an explicit link: every event now carries a parent_id, so history is a tree (which enables branching / forking / navigation). Old files simply have no parent_id field.

BEFORE · 1.29.3 · linear log event-00000 event-00001 event-00002 event-…N order is implicit: file N follows N−1
No parent_id. Sequence = order on disk.
AFTER · 1.33.0 · event tree e0 parent=null e1 parent=e0 e2 parent=e1 eN parent=e(N-1) order is explicit: follow parent_id up
Each event points at its parent. The walk goes child → parent.

Crucially, 1.33.0 is designed to read old flat logs. When an event has no parent_id, the tree walk falls back to "the previous file" — so legacy conversations should load unbranched, with no rewrite:

# openhands-sdk/openhands/sdk/conversation/event_store.py:91
def _effective_parent_id(self, idx, event):
    if event.parent_id == ROOT_PARENT_ID:   # "__root__" → hard stop
        return None
    if event.parent_id is not None:        # explicit link (new events)
        return event.parent_id
    if idx == 0:
        return None                          # genuine first event
    return self.get_id(idx - 1)             # LEGACY fallback: chain to previous file

event_store.py · _effective_parent_id

4How the agent's context is rebuilt

The agent's working view is path_to_root(leaf): start at the current leaf and follow _effective_parent_id until it returns None. Whatever the walk collects is the conversation the LLM sees.

# event_store.py:106 — walk leaf → … → root, then reverse to root-first
def path_to_root(self, leaf_id, limit=None):
    chain, cur = [], leaf_id
    while cur is not None:
        idx = self.get_index(cur)
        chain.append(self[idx])
        cur = self._effective_parent_id(idx, self[idx])   # ← stops on None
    return chain[::-1]

event_store.py · path_to_root  ·  state.py · view / active_branch

So the whole question becomes: where does the walk stop? If any event on the path returns None too early, everything below it is invisible — even though it is right there on disk.

5Where it broke: the resume stamped a false root

When 1.33.0 resumes, it must decide the parent for the first new event. Two functions cooperate:

# state.py:254
def _resolve_active_leaf(self):
    if self.leaf_event_id is not None: return self.leaf_event_id
    if self.head_is_empty:            return None
    if (n := len(self._events)) == 0: return None
    if self._events[n - 1].parent_id is not None:   # ← the trap
        return None
    return self._events.get_id(n - 1)     # legacy tail → chain onto it

# state.py:281
def _stamp_parent_id(self, event):
    if event.parent_id is not None: return event
    parent = self._resolve_active_leaf()
    if parent is None and len(self._events) > 0:
        parent = ROOT_PARENT_ID            # ← "deliberate new root" — but here it's wrong
    return event.model_copy(update={"parent_id": parent})

state.py · _resolve_active_leaf / _stamp_parent_id

On this resume, leaf_event_id was None (not stored by 1.29.3). The last file on disk was not a bare legacy event — it already carried a parent_id (a state-update/error event written during the failed 1.29.3 startup). So the guard self._events[n-1].parent_id is not None was true, _resolve_active_leaf returned None, and _stamp_parent_id concluded "empty head over a non-empty log ⇒ start a new root" and stamped __root__. That is the bug: it should have chained onto the legacy tail, not declared a new root.

6Worked trace (the real conversation)

5,814 events on disk. Indices 0–5565 are legacy (no parent_id); the resume began at 5567.

Eventparent_idWhat the walk does
05811 leaf (agent reply)06d0358e (5808)follow ↑
05808 user msgfa1f595d (5805)follow ↑
… 5805 → 5567 …explicit linksfollow ↑ (165 events)
05567 first post-resume msg__root__STOP → returns None 🛑
05566 … 00000 (5,566 events)none / legacynever reached — orphaned
BEFORE — walk stops at the false root e0 … e5565 (5566) e5567 parent=__root__ e5568 … e5811 (165) ✗ severed walk halts → 165 events unreachable (still on disk) AFTER — re-link to the legacy tail e0 … e5565 (5566) e5567 parent=e5565 e5568 … e5811 (165) ✓ re-linked walk → 5,731 events
Reconnecting event 5567's parent to the last legacy event (5565) restores the full path — verified by simulation: reachable events go 165 → 5,731, back to index 0.

7Recovery

The data is intact; this is a one-field pointer repair. In the offending event file, change the parent from the false root to the last pre-resume event:

# events/event-05567-ae760ea7-….json
- "parent_id": "__root__"
+ "parent_id": "eabf9384-55d0-473a-a314-514372203b5d"   # id of event 05565
Simulated result: the agent's reachable branch grows from 165 → 5,731 events and reaches event index 0 — full history restored. (5,731 < 5,814 because ConversationStateUpdateEvents and abandoned nodes aren't on the linear path, which is expected.)
Do this while the conversation is idle, back up the file first, and reload so the in-memory view rebuilds. A more thorough variant backfills parent_id across all legacy events, but the single re-link is enough because _effective_parent_id already chains legacy events by position.

8What did not happen

  • No data loss. All 5,814 event files are present and unmodified; the browser still renders them.
  • Not an encryption/key problem. Settings decrypt fine; same secret key. This is purely the event-tree walk.
  • Not a condenser/summarization drop. The events were never in the context to be condensed — the walk stopped before reaching them.
  • Not specific to one conversation. Any pre-1.33.0 conversation resumed with a non-bare tail can be stamped with a false __root__ and present as brand-new.
Suggested upstream fix: on resume, when leaf_event_id is unset, resolve the active leaf to the last real event (skipping ConversationStateUpdateEvent/error artifacts) rather than treating "last event already has a parent_id" as grounds for a brand-new root. That keeps legacy tails linked.