OpenHands / OpenHands/software-agent-sdk
[Epic] Streaming: separate the wire format from the durable event record
@VascoSch92 is already working on this.
Since Aug 27, 2026.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
The problem
Streamed text leaves the agent on two channels that know nothing about each other, and only one of them is tracked.
Durable events go through the callback chain and get written to disk. Token deltas are handed straight to PubSub from whichever thread produced them. Nothing orders one against the other, and nothing links them — so the browser puts them back together by comparing strings.
flowchart LR
U["user callbacks<br/>(incl. the publish wrapper)<br/><b>runs 1st</b>"] --> DC["_default_callback<br/><b>append_event — persists — 2nd</b>"]
U -.-> PS
DC --> DISK[("event-{idx:05d}-{id}.json")]
TOK["on_token(chunk)"] --> PS
ACP["ACPAgent bridge<br/>(bypasses llm.py)"] --> TOK
PS["PubSub — no kind filter"]
PS --> WS["WebSocket"] --> BR["Browser<br/>re-marries by string matching"]
PS --> WH["Webhook"]
PS --> TL["Telemetry"]
The root cause is one decision: our socket frame is our disk record.
sockets.py:489sendsevent.model_dump(mode="json", exclude_none=True)event_store.py:217persistsevent.model_dump_json(exclude_none=True)remote_conversation.py:239decodes withEvent.model_validate
Same class, same serialization, same validator — and Event is extra="forbid", so every wire change is a storage migration and every storage change is a wire break. That is why deltas carry no identity: there was nowhere to put it that didn't cost a migration.
The whole problem in one sentence
StreamingDeltaEvent carries text, a random id and a timestamp — and nothing else. No stream identity, no sequence, no attempt number, no pointer to the message that supersedes it.
So the client can't tell a re-streamed retry from new text, can't spot a gap, can't place a delta against a message that arrived beside it, and can't tell when a stream ended. It answers all four by guessing at the text. That is what the 448 lines of handle-event-for-ui.ts are: protocol repair in the view layer.
Nobody else ships a delta this way
Checked against the installed packages, not the documentation.
| What a delta says about itself | How a message starts and ends | |
|---|---|---|
| pi | messageId · contentIndex · kind |
item_started → item_updated → item_finished |
| OpenAI Responses | item_id · output_index · content_index · sequence_number |
output_item.added → .done |
| Anthropic Messages | index |
content_block_start → _stop |
| ACP | nothing | agent_message_chunk, in arrival order |
| OpenHands | nothing | nothing — and on a second, unordered channel |
And ours is not missing upstream — we drop it. _token_streaming_callback (event_service.py:1063-1075) reads delta.content and delta.reasoning_content and nothing else. chunk.id and choice.index are in scope on that line and never read; _publish_stream_delta has no parameter for them. litellm hands us the identity intact and we discard it.
What it breaks
| Issue | What happens | Missing |
|---|---|---|
| OpenHands/OpenHands#15433 | A user message lands mid-stream; the next delta no longer sees a delta last and opens a second bubble. On finalize only the later half is superseded — the first half survives next to the finished message | A turn-scoped slot |
| OpenHands/OpenHands#15432 | One FIFOLock guards the event log, agent, execution status, secrets, stats and HEAD |
Separate locks |
| #4077 | Deltas fire inside the retry boundary, so a retry re-streams everything; delta futures are discarded; the resume cursor compares naive local timestamps | An attempt number, a tracked path, a real cursor |
| OpenHands/OpenHands#15720 | ACP masks each chunk alone, so a secret split across chunks matches neither in the deltas | One boundary both paths cross |
| OpenHands/OpenHands#15493 | Text is committed at whatever granularity the network delivered it | A render clock |
| OpenHands/OpenHands#15511 | The compose form forces stream: true and the server re-decides |
One owner for the flag |
| OpenHands/OpenHands#16331 | Conversation-scoped requests queue behind a process-wide lock | Per-conversation locking |
| #4672 | Every delta is POSTed to your webhooks | A kind filter |
| #4673 | Telemetry counts deltas as conversation activity | The same |
Those last two are one bug, and nobody chose it. Telemetry and webhooks both register through event_service.subscribe_to_events → self._pub_sub.subscribe(...) — the same PubSub that _publish_stream_delta publishes to — and PubSub.__call__ (pub_sub.py:81-102) has no kind filter. The telling detail: StreamingDeltaEvent appears in the agent-server exactly three times — an import (event_service.py:66), a comment (:1048) and its construction site (:1056). No isinstance, no filter, nowhere. Nobody decided to include deltas; nobody had a place to exclude them. That is what making a delta an Event costs — it opts into every consumer of the event bus by default.
Update. #4689 fixed both by making delivery opt-in (Subscriber.receives_streaming_deltas), and in doing so surfaced the third consumer this paragraph predicted — but inverted. _EventSubscriber (conversation_service.py:2433) did not forget to opt out; the standard streaming path had quietly come to depend on it, because it calls update_last_execution_time() and that is what keeps the runtime-api from reaping the pod. It does not opt in, so that heartbeat is now gone: #4695. Two consumers forgot to opt out, and a third came to depend on it. Nobody chose either. That is the cost, stated more precisely than the original claim: a shared bus does not just leak traffic outward, it grows load-bearing dependencies inward that no one declared.
Desired Behavior
Stop shipping the disk record over the socket; then give the stream an identity it can be closed by.
1 · The wire stops being the disk. A new endpoint, /sockets/session/{id}, speaking an envelope that is not an Event. Event doesn't change at all — same class, same extra="forbid", same bytes on disk. It rides inside the envelope as a payload. The old endpoint is frozen as-is; a client speaks one or the other, so the URL is the protocol version — no handshake, no protocol=2 field, no dual-decode path.
The sequence number needs no migration: files are already event-{idx:05d}-{event_id}.json and EventLog.append has self._length in hand at write time (event_store.py:222-227). It just has to return it.
2 · The stream's identity is minted when it opens. StreamContext.open() allocates the event id up front, and the message is built with it:
ctx = StreamContext.open(state) # mints item_id
... # Delta frames reference it
MessageEvent(id=ctx.item_id, …) # the message IS the item
This is safe against the append-only log because minting is not a write. Event.id is already client-minted in-process (base.py:24-26, default_factory=lambda: str(uuid.uuid4())). The log never assigns ids. So this changes only when uuid4() runs. Same bytes, one append, same ordering. If the stream dies the id is simply never used.
A client holding an open slot for item_id sees the durable frame arrive with event.id == item_id and swaps the slot for the real message. That is the entire close protocol — one equality test, on a field that already exists. Gone with it: the close frame, the watermark, the final-order counter, and every line of text comparison in the browser.
3 · Bound the connection in bytes, not in queues. A connection that can't keep up is dropped, not buffered — safe because the durable side has a cursor, so the client reconnects with after_seq=N and loses nothing. Disconnection is the backpressure mechanism.
The protocol
| Frame | Carries | When |
|---|---|---|
Durable |
seq, event |
Once per durable event, after it's on disk. event is the existing JSON, unchanged |
ItemStarted |
item_id, attempt, anchor_seq |
Once per attempt, before the first token. A higher attempt supersedes a lower one |
Delta |
item_id, attempt, order, kind, content |
Per masked chunk. kind is text or reasoning |
ItemAborted |
item_id, attempt, reason |
Only when a stream ends without producing a message |
Rules: Durable never goes missing across a reconnect (within a connection it's recoverable via the cursor). Delta may be dropped freely. Every ItemStarted is retired by exactly one Durable or one ItemAborted — including on cancellation, provider failure, policy rejection and unhandled exception. Progress frames are never replayed. No ordering is promised between two open items.
Plan
| Step | Work | Issue | Needs |
|---|---|---|---|
| 0 | Reproductions and baselines | #4679 | — |
| 1 | Independent repo fixes | #4674 · #4675 · #4676 · #4677 · #4678 · #4077 | — |
| 1b | Deltas get their own fan-out: StreamingDeltaEvent stops subclassing Event; delta-only PubSub; idle heartbeat carried across |
#4696 · #4695 | — |
| 2 | append_event returns its sequence number; publish moves after persist |
#4680 | — |
| 3 | New endpoint + envelope, durable events only; cursor, paged replay, atomic reconnect boundary | #4681 | 2 |
| 4 | Byte-budget admission, one writer per connection | #4681 | 3, 0 |
| 5 | StreamContext mints the id; progress frames across all four entry points |
#4682 | 3 |
| 6 | Canvas turn model | OpenHands/OpenHands#16965 | 5 |
| 7 | Delete StreamingDeltaEvent and its bus; freeze the old endpoint |
#4683 | 6, 1b |
Client endpoint switch (part of step 3). Three sites, two of them now in this repo since OpenHands/typescript-client was archived and moved to clients/typescript:
remote_conversation.py · clients/typescript (#4763) · canvas (OpenHands/OpenHands#16965)
The TypeScript client no longer has its own release train — it publishes off this repo's v* release and tracks the SDK version. But it builds and integration-tests against the agent-server release pinned in clients/typescript/package.json (config.agentServerImage), currently 1.44.0, which predates #4807. That pin has to be bumped to a release containing the new endpoint before #4763 can start.
Closed by #4689, which made delta delivery opt-in rather than opt-out:
#4672 (deltas POSTed to webhooks) · #4673 (telemetry counts deltas)
That fix is a filter, and it is deliberately interim — its own description says it "does not preclude the epic's structural direction of taking deltas off the bus entirely." Step 1b is that structural half, and it removes the flag #4689 added. It also has to restore the heartbeat that fix dropped (#4695).
Publish-after-persist (step 2), stated precisely — because it sounds scarier than it is. Nothing partial is ever written and no event reaches disk before it's finished; this reorders two existing callbacks over an already-complete event. Today composed_list = callback_list + [_default_callback] (local_conversation.py:427) puts the user callbacks — including the one that publishes — ahead of _default_callback, which calls state.append_event(e) (:418), and compose_callbacks runs them in list order (base.py:473-476). So the socket is told before the disk is written, and if the append then raises we have already announced an event that does not exist. Swap them. The log ends up taking fewer writes than today, since deltas are never appended at all.
Six tests that don't exist
The current streaming test covers deltas being emitted and suppressed, and nothing else. Missing: retry × identity · reconnect × slot discard · replay × live interleave · ACP × split-chunk secret · error-after-tokens × exactly one retire · wedged connection × publisher never blocks. All six fail today.
Not modelled yet
Sub-agent streams sharing the channel (#3907) · StreamContext across a mid-turn model switch · whether the single-pass masker compiles cheaply enough for conversations with many secrets.
Read against software-agent-sdk @ a1a2cdb (rel-1.43.0) and OpenHands @ a80b1bb. Every code reference above was verified against the checkout.
Acceptance Criteria
- Step 0 baselines landed (#4679) — the byte budget is derived from a measured frame-size distribution, not a guess.
- Step 1 independent fixes landed: #4674 · #4675 · #4676 · #4677 · #4678 · #4077.
- Step 1b landed (#4696) —
StreamingDeltaEventis not anEvent, deltas have their own fan-out, the frame on the wire is unchanged, andreceives_streaming_deltasis gone. - The runtime idle timer is reset during a long stream that produces no durable events (#4695), and stays so after step 1b.
- Step 2 landed (#4680) —
append_eventreturns its sequence number and publish happens after persist. - Steps 3+4 landed (#4681) —
/sockets/session/{id}serves durable events in a non-Eventenvelope, with cursor, paged replay and byte-budget admission. - Step 5 landed (#4682) —
StreamContextmints the identity; everyItemStartedis retired by exactly oneDurableorItemAborted. - Step 6 landed (OpenHands/OpenHands#16965) — canvas keys slots by event id and
handle-event-for-ui.tscontains no text comparison. - Step 7 landed (#4683) —
StreamingDeltaEventis deleted outright and the old endpoint is documented as deprecated. -
Eventis unchanged on disk throughout: same class, sameextra="forbid", same bytes.
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.
Assessment
This issue has not been assessed yet.