OpenHands / OpenHands/software-agent-sdk

Streaming step 1b: StreamingDeltaEvent stops subclassing Event and gets its own fan-out

Open
#4,696 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

architecture enhancement ready-for-dev
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:

  • source must stay. The canvas guard isBaseEvent (OpenHands:src/types/agent-server/type-guards.ts:45) requires id, timestamp and a source from 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 #4689 behaviour. 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
  • StreamingDeltaEvent no longer subclasses Event; isinstance(delta, Event) is False.
  • Its serialized frame is byte-identical to today's, asserted by a test, including kind and source.
  • Deltas are published to a delta-only PubSub, and Subscriber.receives_streaming_deltas (added in #4689) is removed as dead code.
  • RemoteConversation decodes delta frames without routing them through Event.model_validate, and durable-event callbacks keep their Callable[[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 StreamingDeltaEvent in the Event union; typescript-client regenerated.
  • Event itself is untouched: same class, same extra="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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.