software-agent-sdk · streaming
A plan to stop StreamingDeltaEvent inheriting from Event, answering enyst on issue #4672. The wire stays byte-identical, so no deployed client breaks.
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.
One file, one base class, three fields promoted from inherited to explicit.
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
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
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.
Beyond tidiness. Two of these are load-bearing for the rest of the epic.
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.
{"content":"x","item_id":"abc","order":3} succeeds on the proposed class and raises ValidationError on the current Event-based one.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.
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.
components.schemas.Event, referenced from the events, events/search and events/{id} paths.Each of these was found by testing, not by reading. Skipping any one of them ships a silent regression.
source makes the browser discard every deltaThe 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
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
EventRemoteConversation 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
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
Stage A and B must ship together or pyright fails. Stage C must ship in the same release or Python streaming clients go quiet.
openhands-sdk/openhands/sdk/event/streaming_delta.py, change the base from Event to DiscriminatedUnionMixin and declare id, timestamp and source explicitly.openhands/sdk/event/__init__.py untouched, so every import path still resolves.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.
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._publish_stream_delta at the new bus.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.
remote_conversation.py, inspect the parsed kind before calling Event.model_validate and route delta frames to StreamingDeltaEvent.model_validate.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.
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.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.StreamingDeltaEvent from its local OpenHandsEvent union so the compiler stops implying deltas belong in the event store.Version skew, worked through in both directions.
| Client | Against a new server | Why |
|---|---|---|
| Browser / canvas | Unaffected | Runtime guards are string and structural checks. The frame is identical. |
| TypeScript client | Unaffected | Types are compile-time only. Regeneration is cosmetic. |
| Older Python SDK | Unaffected | Kind resolution runs against the client's own class registry, which still lists the delta under Event. |
| New Python SDK without stage C | Streaming lost | Event.model_validate raises Unknown kind, caught and logged once per token. |
Event in a process where the current class is still registered. It resolves.Two files change mechanically. Two behaviours are worth a new test each.
tests/agent_server/test_event_streaming.py subscribes its collector directly to the durable bus in six places. Each must move to the delta bus.tests/sdk/event/test_streaming.py asserts on the dumped kind, which is unchanged, so it should keep passing untouched. That is the regression guard for the rename trap.id, timestamp and a valid source. This is the guard against the canvas discarding deltas.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.