Skip to content

fix(sdk): degrade per event when a stored event cannot be deserialized - #4114

Draft
JoshKappler wants to merge 1 commit into
OpenHands:mainfrom
JoshKappler:fix/degrade-per-event-on-unreadable-event
Draft

fix(sdk): degrade per event when a stored event cannot be deserialized#4114
JoshKappler wants to merge 1 commit into
OpenHands:mainfrom
JoshKappler:fix/degrade-per-event-on-unreadable-event

Conversation

@JoshKappler

Copy link
Copy Markdown

HUMAN:


AGENT:

The change and this description were prepared by an AI agent (Claude) on behalf of Josh Kappler. Leaving the HUMAN section and the ready-for-review flip to him, per the template. Opening as a draft for that reason.

Why

A conversation used a custom tool whose observation subclasses Observation with kind="CanvasUIObservation". Resuming without that module imported leaves the kind unregistered, so those events do not deserialize. Event.model_validate_json raises, the error propagates up through rebuild_view -> path_to_root, and the agent-server startup scan wraps the whole per-conversation load in one try/except. The conversation is skipped: it 404s and never appears in listings, even though every other event is intact on disk. One unknown event kind takes down the whole conversation.

#3754 already took the skip-unreadable approach for the pagination and search path. This does the same for the load path.

Summary

  • path_to_root skips an event it cannot deserialize instead of raising. It recovers the skipped event's parent_id from the raw payload (a plain top-level string, still readable when the event as a whole does not validate), so the walk stays on its own branch instead of falling back to the linear chain and splicing in a sibling. That recovered id comes from a payload that just failed validation, so it is only trusted once it names an event we actually hold, and otherwise falls back to the linear chain.
  • _resolve_active_leaf (the legacy scan, taken when no leaf is persisted) and the RUNNING crash-recovery scan in EventService.start now read per event as well. Both scan the full log, so either one raising still dropped the conversation.
  • Shared UNREADABLE_EVENT_ERRORS, read_event_or_none and readable_events in events_list_base.py, using the same exception set as [codex] Skip unreadable events during pagination #3754.

Issue Number

Closes #4080

How to Test

Two processes, because the point is that the writer's custom kind is not registered in the reader. writer.py defines CanvasUIObservation and persists a conversation using it; reader.py resumes that same conversation and never imports it.

# writer.py (abridged): a normal turn, user message -> tool call -> custom observation -> reply
class CanvasUIObservation(Observation):
    result: str
    @property
    def to_llm_content(self): return [TextContent(text=self.result)]

state = ConversationState.create(id=conversation_id, agent=agent, workspace=..., persistence_dir=...)
state.append_event(MessageEvent(source="user", llm_message=Message(role="user", content=[TextContent(text="draw a chart")])))
state.append_event(action)  # ActionEvent, tool_name="canvas"
state.append_event(ObservationEvent(source="environment", observation=CanvasUIObservation(result="canvas rendered"),
                                    action_id=action.id, tool_name="canvas", tool_call_id="call_canvas_1"))
state.append_event(MessageEvent(source="agent", llm_message=Message(role="assistant", content=[TextContent(text="Done.")])))

# reader.py: same call, in a process that never imports CanvasUIObservation
state = ConversationState.create(id=conversation_id, agent=agent, workspace=..., persistence_dir=...)
print(f"LOADED: {len(state.events)} events on disk, {len(list(state.view.events))} in the rebuilt view")
uv run python writer.py /tmp/persist /tmp/work
uv run python reader.py /tmp/persist /tmp/work <conversation_id>

Unit tests:

uv run pytest tests/sdk/conversation/ tests/sdk/event/ -q          # 871 passed
uv run pytest tests/agent_server/test_event_service.py -q          # 109 passed

Video/Screenshots

Terminal, no GUI surface. Same on-disk conversation in both runs, written once by writer.py.

On current main (v1.36.0):

wrote 4 events
observation kind on disk: CanvasUIObservation
CONVERSATION_ID=be02ed5e-525e-409c-938c-c6099c95883d

LOAD FAILED: ValidationError
   1 validation error for Event
   -> agent-server logs error_loading_event_service and 404s the conversation

With this branch, reading that same conversation directory:

INFO  Resumed conversation be02ed5e-525e-409c-938c-c6099c95883d from persistent storage
LOADED: 4 events on disk, 2 in the rebuilt view
   kept MessageEvent (d04de660)
   kept MessageEvent (aa5eae55)

The unreadable observation is skipped and both messages survive. The action it would have paired with is dropped from the view by ToolCallMatchingProperty, so the view never carries a tool call with no result.

Each of the three new regression tests was also confirmed to fail without its fix: ValidationError for the legacy-tail and crash-recovery tests, KeyError: 'ffffffff' for the unknown-parent test.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • This skips the unreadable event. If you would rather retain it opaquely instead (the dummy action/observation idea in Research strategies for updating toolset while maintaining backward compatibility with persisted sessions #2667), that is a larger change and I am happy to follow up separately.
  • EventLog.__iter__ and __getitem__ still raise, deliberately, so len(list(log)) == len(log) stays honest. Reads outside the load path (the fork path in local_conversation.py, the goal loop, get_agent_final_response) therefore still surface a ValidationError. Those fail one call rather than dropping a conversation at startup, so I left them alone. Say the word if you want them covered here too.
  • The unreadable event is re-read on each branch walk on purpose, since its kind may become registered later once the module defining it is imported. Only the parent recovery and the warning are memoized, so a 1,200 event conversation does not re-warn on every agent step.

An event whose kind is not registered in this process (a custom tool's
observation whose module was never imported, or an event from a newer
writer) failed the entire conversation load. The agent-server startup scan
drops a conversation on any load exception, so one unknown event 404'd a
conversation whose other events were all intact on disk.

Skip the unreadable event instead of raising, across every read in the load
path: path_to_root, the legacy leaf scan in _resolve_active_leaf, and the
RUNNING crash-recovery scan in EventService.start. path_to_root recovers the
skipped event's parent_id from the raw payload (it stays readable when the
event as a whole does not validate) so the walk stays on its own branch
instead of falling back to the linear chain and splicing in a sibling; the
recovered id falls back to that chain unless it names an event we hold.

Extends the approach OpenHands#3754 took for the pagination path.

Closes OpenHands#4080
@all-hands-bot

Copy link
Copy Markdown
Collaborator

[Automatic Post]: It has been a while since there was any activity on this PR. @JoshKappler, are you still working on it? If so, please go ahead, if not then please request review, close it, or request that someone else follow up.

This comment was created by an AI agent (OpenHands) on behalf of the user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One unregistered event kind fails the entire conversation load (should degrade per-event)

2 participants