software-agent-sdk · streaming

Unparenting the Delta

A plan to stop StreamingDeltaEvent inheriting from Event, answering enyst on issue #4672. The wire stays byte-identical, so no deployed client breaks.

Read against origin/main @ e38e02b38 Epic #4671 Fixes #4672 · #4673

The change is smaller than it sounds. Because kind is derived from the class name, keeping the name StreamingDeltaEvent and re-declaring source produces a frame identical to today's, field for field. The browser needs no change at all. The only code that must move is server-side typing and the Python client's decode branch.

01The shape change

One file, one base class, three fields promoted from inherited to explicit.

Today

class StreamingDeltaEvent(Event):
    # id, timestamp, source, parent_id
    # inherited from Event
    # extra="forbid", frozen=True

    source: SourceType = "agent"
    content: str | None = None
    reasoning_content: str | None = None

Proposed

class StreamingDeltaEvent(DiscriminatedUnionMixin):
    id: str = Field(default_factory=…uuid4)
    timestamp: str = Field(default_factory=…)
    source: SourceType = "agent"
    content: str | None = None
    reasoning_content: str | None = None
    # no parent_id — deltas have no tree
    # extras tolerated by default
verified I built the proposed class and diffed its dump against the current one. Identical, minus id and timestamp which are random per instance: {"source":"agent","content":"hel","kind":"StreamingDeltaEvent"}

This is not a novel shape. The bash event stream already does exactly this: BashEventBase is a plain DiscriminatedUnionMixin with its own id and timestamp, its own pub-sub instance, its own subscriber class and its own socket endpoint. The delta is the same kind of object and should follow the same pattern.

02Why it is worth doing

Beyond tidiness. Two of these are load-bearing for the rest of the epic.

It unblocks stream identity without a wire break

This is the strongest reason and it is easy to miss. Event sets extra="forbid". Every additive field is therefore a breaking change for any client on an older schema. A delta on the default config tolerates unknown fields, so item_id, attempt and order can be added later and older clients will simply ignore them.

verified Validating {"content":"x","item_id":"abc","order":3} succeeds on the proposed class and raises ValidationError on the current Event-based one.

It makes the webhook and telemetry bugs structural, not conventional

Reparenting forces the pub-sub split, because a non-Event cannot be published to a PubSub[Event] without a type error, and pyright runs in pre-commit. Once deltas live on their own bus, webhooks and telemetry cannot receive them. They are registered on a different object. That is what closes #4672 and #4673 for good rather than by a filter someone can forget.

It makes the REST contract honest

The public OpenAPI document lists StreamingDeltaEvent in the Event union, which is reachable from three endpoints. Deltas are never persisted, so those endpoints can never return one. The schema currently advertises a response that cannot happen.

verified Exported the spec and found the delta in components.schemas.Event, referenced from the events, events/search and events/{id} paths.

03Four traps

Each of these was found by testing, not by reading. Skipping any one of them ships a silent regression.

Dropping source makes the browser discard every delta

The canvas guard isBaseEvent requires id, timestamp and a source drawn from a fixed set. A delta without source fails the guard, never reaches the batcher, and streaming stops with no error anywhere. Keep source: SourceType = "agent" as an explicit field.

trap

Renaming the class silently breaks the browser too

kind is self.__class__.__name__. The canvas matches it with a literal string comparison against "StreamingDeltaEvent". Renaming the class to StreamingDelta changes the wire value and every delta stops matching. Keep the name, however redundant the Event suffix now reads.

trap

The new Python client must decode deltas before Event

RemoteConversation decodes every socket frame with Event.model_validate, which raises Unknown kind once the delta leaves the union. The call site catches and logs, so the failure is a log line per token rather than a crash, and streaming is silently lost. Branch on the kind before validating.

trap

Reparenting alone does not stop webhooks receiving deltas

Widening the bus to a union would keep every subscriber receiving them. Type checking would catch almost none of it, because four of the six subscribers are declared as bare Subscriber rather than Subscriber[Event]. The split bus is the fix; the reparenting is what forces it.

trap

04The stages

Stage A and B must ship together or pyright fails. Stage C must ship in the same release or Python streaming clients go quiet.

Ano deps

Reparent the class

  • In openhands-sdk/openhands/sdk/event/streaming_delta.py, change the base from Event to DiscriminatedUnionMixin and declare id, timestamp and source explicitly.
  • Leave the export in openhands/sdk/event/__init__.py untouched, so every import path still resolves.
  • Update the docstring: it currently explains why the event is not persisted, which becomes self-evident once it is not an event.

Note that isinstance(delta, Event) now returns False. That is the intended semantic change and the reason it cannot ship silently in a patch release.

Bwith A

Split the pub-sub bus

  • In event_service.py, add a second field alongside _pub_sub, holding a PubSub[StreamingDeltaEvent], plus subscribe_to_deltas and unsubscribe_from_deltas mirroring the existing pair.
  • Point _publish_stream_delta at the new bus.
  • Close the new bus alongside the existing one in the service teardown.
  • In sockets.py, have the conversation websocket subscribe its subscriber to both buses and unsubscribe from both on disconnect. _send_event needs only a widened annotation; model_dump already works on any model.

Leave has_external_subscribers reading the durable bus only. The websocket still subscribes there for durable events, so idle-eviction accounting is unchanged.

Csame release

Teach the Python client the new kind

  • In remote_conversation.py, inspect the parsed kind before calling Event.model_validate and route delta frames to StreamingDeltaEvent.model_validate.
  • Decide where a decoded delta goes. The existing callback is typed Callable[[Event], None], so either widen it or give deltas their own callback list, mirroring how LocalConversation already keeps token_callbacks separate from callbacks.

The second option matches what the SDK already does everywhere else and keeps the durable callback signature honest.

Dafter A

Regenerate the published contract

  • The exported OpenAPI drops the delta from the Event union and keeps it as a standalone schema. Nothing in CI diffs the spec against a committed golden file, so there is no fixture to update; the spec is exported at release time.
  • In typescript-client, regenerate src/generated/agent-server-schema.ts. The alias in src/events/types.ts and the export in src/index.ts keep working, now pointing at a standalone type.
  • The canvas needs no runtime change. Optionally drop StreamingDeltaEvent from its local OpenHandsEvent union so the compiler stops implying deltas belong in the event store.

05Who breaks

Version skew, worked through in both directions.

ClientAgainst a new serverWhy
Browser / canvasUnaffectedRuntime guards are string and structural checks. The frame is identical.
TypeScript clientUnaffectedTypes are compile-time only. Regeneration is cosmetic.
Older Python SDKUnaffectedKind resolution runs against the client's own class registry, which still lists the delta under Event.
New Python SDK without stage CStreaming lostEvent.model_validate raises Unknown kind, caught and logged once per token.
verified The old-client case was simulated by validating the proposed frame through Event in a process where the current class is still registered. It resolves.

06Tests

Two files change mechanically. Two behaviours are worth a new test each.

07What this does not do

Scope boundary, so this is not mistaken for the epic.

This leaves the two-channel problem entirely intact. Deltas still ride the same socket as durable events, still carry no stream identity, and the browser still reconciles them by comparing text. Ordering between the two channels is still unspecified.

What it does is remove the reason the identity fields could not be added: the strict schema inherited from Event. It is a prerequisite for step 5 of the epic rather than a substitute for it, and it delivers the two bug fixes early, without touching the wire.

One finding worth adding to #4673 while here. The subscriber that refreshes the conversation's idle clock also rewrites updated_at and the server's last-execution timestamp on every single token. After the bus split it stops seeing deltas. Eviction is unaffected, because the sweep already skips conversations with a running task.