OpenHands / OpenHands/software-agent-sdk
Streaming step 1b: StreamingDeltaEvent stops subclassing Event and gets its own fan-out
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Problem or Use Case
Part of #4671. This is the step that answers @enyst's question on #4672:
I think maybe deltas should not be Events. They don't seem to match Event in more than one way, persistence, pubsub, strictness of the format, shouldn't affect compatibility with anything except their direct clients (visual clients typically)…
The answer is yes, and nothing at runtime requires it. StreamingDeltaEvent is never persisted, is not LLMConvertibleEvent, and never appears in replay. The socket serializer calls model_dump on it, which works on any Pydantic model — _send_bash_event (sockets.py:548) already does exactly that with BashEventBase, which is a plain DiscriminatedUnionMixin (models.py:576) with its own id and timestamp, its own PubSub (bash_service.py:34), its own subscriber and its own endpoint. The delta is the same kind of object.
#4689 fixed the fan-out symptom with an opt-in flag. This is the structural half its description points at: "it does not preclude the epic's structural direction of taking deltas off the bus entirely."
The reason it matters beyond tidiness is extra="forbid" on Event (base.py:21). Every additive field is a break for any client on an older schema, which is why the delta carries no identity today. Verified with uv run python: validating {"content": "x", "item_id": "abc", "order": 3} succeeds against a DiscriminatedUnionMixin delta and raises ValidationError against the current Event-based one. That is what lets StreamContext be built and tested against the endpoint that exists today rather than only against the one step 3 introduces.
Desired Behavior
StreamingDeltaEvent stops subclassing Event and becomes a DiscriminatedUnionMixin with explicit id, timestamp and source, and deltas get their own fan-out rather than a filter on the durable one.
The wire must not change. kind is self.__class__.__name__ (models.py:199-201), so keeping the class name keeps the wire value. Verified: the dump is identical to today's, field for field, once source is re-declared — {"id": …, "timestamp": …, "source": "agent", "content": "hel", "kind": "StreamingDeltaEvent"}.
Three traps, all found by testing rather than reading:
sourcemust stay. The canvas guardisBaseEvent(OpenHands:src/types/agent-server/type-guards.ts:45) requiresid,timestampand asourcefrom a fixed set. Drop it and every delta is discarded silently, with no error anywhere.- The class must not be renamed. The canvas matches
kind === "StreamingDeltaEvent"as a literal string. - The idle heartbeat must come across. See the separate bug filed against the
#4689behaviour. Whatever resets the runtime idle timer during a stream has to survive this move too.
Version skew is benign in the one direction that matters. An older Python client resolves kind against its own local class registry, which still lists the delta under Event, so the identical frame still decodes. Verified. Only the new client needs the decode branch, in the same release.
Acceptance Criteria
-
StreamingDeltaEventno longer subclassesEvent;isinstance(delta, Event)isFalse. - Its serialized frame is byte-identical to today's, asserted by a test, including
kindandsource. - Deltas are published to a delta-only
PubSub, andSubscriber.receives_streaming_deltas(added in #4689) is removed as dead code. -
RemoteConversationdecodes delta frames without routing them throughEvent.model_validate, and durable-event callbacks keep theirCallable[[Event], None]signature. - The runtime idle timer is still reset during a long stream that produces no durable events.
- A test asserts a webhook subscriber and a telemetry subscriber receive no delta during a streamed run, holding the guarantee #4689 established.
- The exported OpenAPI no longer lists
StreamingDeltaEventin theEventunion;typescript-clientregenerated. -
Eventitself is untouched: same class, sameextra="forbid", same bytes on disk.
Alternatives Considered
Leave it until step 7 (#4683) deletes the class outright. Cheaper in total work, but step 7 depends on step 6, so it forfeits the ability to develop StreamContext against the current endpoint, and leaves the fan-out guarantee resting on a flag each new subscriber must remember not to set.
Priority / Severity
Medium
Feature Area
Agent Server
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing StreamingDeltaEvent and the existing _send_bash_event path in sockets.py:548, then read its definitions in models.py and the Event constraints in base.py. Inspect the canvas isBaseEvent guard and the RemoteConversation decode path before running the relevant tests. Done means the wire format is unchanged, deltas use separate fan-out, idle heartbeats and subscriber isolation still work, and the OpenAPI client is regenerated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, typescript
- Domain
- api, backend, testing
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100