Internal technical note · agent-server & canvas

Token streaming: the two-channel problem

Streaming text leaves the agent on two paths that know nothing about each other. Only one of them is tracked, nothing orders them, and the browser puts them back together by comparing strings. Seven open issues come out of that single gap.

Read against software-agent-sdk a1a2cdb (rel-1.43.0) and OpenHands a80b1bb (main), both 2026-08-21.
Comparisons drawn from the installed openai, anthropic and acp packages, and from earendil-works/pi c49906e.

When an agent produces a message, that message reaches the browser twice. Once as a durable event — appended to the conversation log, written to disk, and published to every subscriber. And once as a stream of token deltas, emitted while the model is still generating so the user sees text appear.

Those are two representations of the same thing. Nothing in the system says so.

01How it works today

Agent thread one composed callback chain user callbacks AsyncCallbackWrapper schedules publish — 1st _default_callback append_event — persists, 2nd persisted log event-00042-….json on_token(chunk) future discarded, 1 per token ACP bridge never enters llm.py PubSub gather, 1 coroutine per subscriber no queue · no filter WebSocket Webhook POSTs tokens too Telemetry Browser re-marries the two paths by comparing strings
The dashed line is the one nobody waits on. Durable events and token deltas converge on the same unfiltered fan-out, having taken paths with no ordering relationship — and the browser is left to work out which delta belonged to which message.

Three details make this worse than it first looks, and every one of them is load-bearing.

02What the delta doesn't say

Here is the entire payload of a streamed token, minus the boilerplate every event carries:

The whole problem, in one class

StreamingDeltaEvent carries content, reasoning_content, a randomly generated id and a wall-clock timestamp. There is no stream identity, no sequence number, no attempt counter, and no pointer to the message that will supersede it.

So a client receiving one of these cannot answer any of the four questions it needs to answer:

It answers all four by inspecting the text. That is what the 448 lines of handle-event-for-ui.ts are for — protocol repair in the view layer, complete with prefix matching, an ordered-segment search, a whitespace-trimmed candidate set, and a sniff for a <function= marker to detect a raw tool call that leaked into the visible text.

None of that is bad code. It is careful code compensating for information that was thrown away upstream.

03Nobody else ships a delta this way

This is not an unsolved problem. It is a solved problem we solved differently. Field lists below come from the installed packages and from source, not from documentation.

SystemWhat a delta says about itselfHow a message starts and ends
pimessageId · contentIndex · kinditem_started → item_updated → item_finished
OpenAI Responsesitem_id · output_index · content_index · sequence_numberoutput_item.added → .done
Anthropic Messagesindex — the message is implicit, one per streamcontent_block_start → _stop
Agent Client Protocolnothingagent_message_chunk, in arrival order
Oursnothingnothing — and on a second, unordered path

Every one of the other four puts an identity on the increment and a lifecycle around it, and delivers both on a single ordered stream. That combination is what makes their clients simple: a delta names its target, so the client updates a keyed slot instead of guessing.

ACP is the interesting near-miss. Its ContentChunk is _meta plus content and nothing else, so it has no identity either — and because our ACP bridge sits on top of it, we inherit that gap on that path regardless of what we do elsewhere. _meta is ACP's sanctioned extension point, so identity can be added there without forking the protocol.acp/schema.py:4289

For a sense of the difference in cost: pi's entire client-side stream reconciliation is 101 lines. It keeps a disposable overlay keyed by item id and throws the whole overlay away whenever authoritative state arrives, because a delta that names its target never has to be matched to anything.

04What it breaks

These are not seven independent bugs. They are seven symptoms of one missing relationship.

IssueWhat actually happensWhat's missing
#15433split textA user message arrives mid-stream, so the next delta no longer sees a delta last in the array 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.
#15432message ignoredA single lock guards the event log, the agent, execution status, secrets, stats and the conversation head. Message intake contends with the run loop at every step boundary, and whether it gets picked up depends on a lock race plus a re-arm flag.Separate locks.
sdk#4077six defectsDeltas fire inside the retry boundary, so a retry re-streams everything. An empty stream returns None and the following assertion raises an error the retry logic does not catch. Delta futures are discarded, so the drain barrier cannot see them. State updates are a bare await that can hang the run. The resume cursor compares naive local timestamps.An attempt number, one tracked path, a real cursor.
#15720security, P1The ACP path masks each chunk on its own, so a secret split across two chunks matches neither piece. On the standard agent path it is worse: agent.py never calls mask_secrets_in_output at all — the only places that mask model-adjacent output are the terminal and MCP tool results.One redaction boundary both paths cross.
#15493Text is committed to the view at whatever granularity the network happened to deliver it, so rendering stutters.A render clock.
#15511The compose form forces streaming on, and the server then independently re-decides whether it is available.One owner for the flag.
#16331not pinnedServing a workspace file goes through a process-wide lock that is also held across conversation start, fork, delete and eviction — so a file preview can block behind unrelated lifecycle work.Per-conversation locking.
And one that has not been filed

Every token delta is POSTed to configured webhooks. Deltas are published into the same fan-out the webhook subscriber listens on, nothing filters by event kind, and the webhook buffer flushes every five events by default. That is roughly one HTTP request per five tokens, per configured webhook, for the entire duration of a response.

conversation_service.py:2233 · config.py:75

05The fix

The shape follows from the diagnosis rather than from taste. Three changes, and the first one is what makes the other two available.

TODAY — THE FRAME IS THE RECORD Event extra="forbid", frozen persisted log model_dump_json WebSocket frame model_dump one class, two destinations every wire change is a migration every storage change is a wire break PROPOSED — ONE ORDERED CHANNEL time → Durable seq = n Event ItemStarted item_id = A anchor_seq = n Delta A · 0 Delta A · 1 Delta A · 2 Durable seq = n+1 Event id = A the message's own id retires the slot no close frame no watermark no text matching event.id == item_id
Break the coupling at the top and the design at the bottom becomes available. Because the stream's identity is minted when the stream opens, the message that finishes it already carries the matching id — and that is the whole close protocol.

1 · Stop shipping the storage format over the socket

Today the socket frame and the disk record are literally the same class, serialized the same way and validated by the same strict validator. That is a single decision with a long tail: it is why a delta cannot carry identity without a storage migration, why an additive field fails closed on older clients, and why the transient delta had to be modelled as a durable event class in the first place — it is never persisted, it is only in that hierarchy because the socket speaks nothing else.

Introduce a new endpoint speaking an envelope that is not an event. The event itself does not change at all — same class, same validator, same bytes on disk. It rides inside the envelope as a payload. Everything the protocol needs now lives on the envelope, where adding a field costs nothing and breaks nobody.

The URL is the protocol version

The old endpoint is frozen and deprecated as it stands; a client speaks one or the other. No handshake, no version field, no dual-decode path. This is worth stating plainly because it is the cheapest possible way to get versioning we currently do not have: pi's session files are on format v4 while its wire protocol is still on v1 — three storage rewrites, zero wire breaks.

The cursor comes free. A monotonic per-conversation index is already durable in every event filename, and the writer has it in hand at write time. Returning it from the append call and putting it on the envelope adds nothing to disk, so there is no migration and no compatibility break.

2 · Mint the message's identity when the stream opens

Today the delta exists before the durable event does, so nothing can name the relationship. Invert it: allocate the event id up front, and construct the finished message with that id.

The consequence

A client holding an open slot for item_id sees the durable frame arrive carrying 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 terminal marker, the "has the closing event arrived yet" bookkeeping, and every line of text comparison in the browser. What remains is one integer that says where in the transcript to place the slot, which exists only to stop a mid-stream user message from splitting the bubble.

A stream that dies without ever producing a message still needs a terminal frame — that is what ItemAborted is for, and §06 covers what guarantees it always fires.

3 · Bound each connection in bytes, and drop the ones that fall behind

Admission becomes a synchronous check that never blocks the producer: if a write would exceed the connection's byte budget it is rejected immediately, rather than queued or waited on. The budget is derived — a small multiple of the largest legal frame — rather than picked out of the air. Ordering comes from a single write chain, which is one writer per connection in a handful of lines.

A consumer that cannot keep up is disconnected, not buffered. That is only safe because the durable side is replayable: the client reconnects with its cursor and loses nothing. Disconnection is the backpressure mechanism, and it is also why one socket is now sufficient where a second one seemed necessary before.

Three fixes worth doing regardless

None of these are streaming work, all three stand alone, and each removes a mechanism the problems above depend on: split the overloaded lock into its separate concerns, which resolves the dropped-message issue by itself; make the lifecycle lock per-conversation rather than process-wide; and drop the full directory listing that runs on every single append while the lock is held.

06The protocol

Four frames on one ordered channel. Two of them carry durable state; two are progress hints the client is free to lose.

FrameCarriesWhen it is sent
Durableseq · eventOnce per durable event, after it is safely on disk. The event payload is the existing JSON, unchanged.
ItemStarteditem_id · attempt · anchor_seqOnce per attempt, before the first token. A higher attempt supersedes a lower one — that is how a retry announces itself instead of silently re-streaming.
Deltaitem_id · attempt · order · kind · contentPer masked chunk. kind separates visible text from reasoning, the way all three vendor protocols do it.
ItemAborteditem_id · attempt · reasonOnly when a stream ends without ever producing a message.

The rules that matter

Reconnecting without a seam

Today the socket subscribes and then replays history, so live events interleave with the backlog and the client sees them out of order. Four steps close that window:

  1. Take the state lock and register the subscriber in buffering mode — it queues privately and sends nothing.
  2. In the same acquisition, read the high-water sequence number. Append and enqueue happen back to back under this lock, so nothing can be on disk but not yet queued at that instant.
  3. Release the lock, then replay history from the client's cursor up to that mark in pages, yielding between them so the loop stays responsive.
  4. Flush the buffer, discarding anything at or below the mark, and go live.

pi does the equivalent in about twenty lines — subscribe, snapshot, then flush while still buffering so reentrant events keep their order. Ours needs the page loop only because our history lives on disk rather than in memory.

Guaranteeing a stream always closes

A stream context wraps each attempt in try/finally: opening mints the id and reads the anchor, streaming masks and stamps each chunk, and the message is built with the minted id. If no message was produced, the finally emits exactly one ItemAborted. It has to sit at four call sites — both agents, on both their synchronous and asynchronous paths — because the LLM-layer call context cannot reach conversation state or the secret registry. The retry wrapper contributes exactly one thing, the attempt number, and that is the one thing it owns.

This is not hypothetical. The asynchronous step function alone has three except … return branches that emit an error message and return without ever touching the stream, leaving the client's slot open forever. pi enforces the same invariant one level lower: its stream function may not throw, and failures must come back as terminal events inside the stream. A stream that cannot raise cannot be stranded; our try/finally is that rule retrofitted onto code that can.

Two more things on the critical path

Redaction. The masking helper resolves secret sources before it does anything else — its own comment warns that resolution "may do blocking network I/O" — and then runs one string replacement per secret across the whole text. On the synchronous path that happens while the state lock is held. On the stream path, replace it with a snapshot compiled once when the stream opens: resolved values only, a single-pass matcher, no lock and no I/O while streaming. The trade-off, stated rather than buried: a secret registered after the stream opened will not be masked in that stream's deltas, but it is masked in the durable message, which is the authoritative one.

Publish after persist. Today the publishing callback runs before the persisting one. Swap them, so publication happens on a successful append and only then — both under the lock the run loop already holds, so the two cannot be separated. If the append raises, nothing is published.

07What this removes

Worth recording, because the earlier drafts of this design accumulated machinery that the substrate change simply deletes.

Previously neededNow
A second socket dedicated to the streamOne endpoint — the byte budget plus the cursor make a slow consumer safe to drop
A parallel frame hierarchy sitting alongside the event socketOne envelope, with the event riding inside it
Three stream frames plus a close reasonTwo, retired by the durable message itself
A closing sequence pointer, a final-order counter, and a watermark comparison to answer "has it closed yet?"event.id == item_id
A stream identifier unrelated to any durable identityThe item id is the message id, minted at open
Seven named queue capacities, none of them sizedOne byte budget per connection, derived from the largest legal frame
Protocol negotiation, transient-event filtering, an unknown-kind gateStructural — a new endpoint has no legacy clients, and deltas never reach the event bus at all
What survives, because it is genuinely needed

The stream context at four entry points with its try/finally; the attempt number from the retry wrapper; snapshot masking with a carry-over tail; masking the durable message too; non-blocking admission with resumable disconnect; one writer per connection with a deadline; the acceptance barrier; publish-after-persist; the atomic replay boundary; and turning the fan-out from a callback list into a real delivery component.

That last one is the actual new machinery here, and no amount of substrate change avoids it.

08Order of work

Steps 1 through 3 are independently shippable and independently valuable. Nothing before step 5 is visible to a user.

StepWorkNeeds
0Reproductions and baselines. A delta-flood stress test, a retry-injecting fake model, and measurements of lock contention, append cost against conversation length, fan-out cost against connection count, and the frame-size distribution the byte budget is derived from. Without these, the third change above is unverified.—
1The three standalone repo fixes, redaction of the durable message, the retryable-error parity fix, and giving the token callback the accumulated text alongside each increment so a failed stream can still emit what it has. All independent of the protocol.—
2The append call returns its sequence number; publication moves after persistence. Invisible on the wire.—
3The new endpoint and envelope, carrying durable frames only. Cursor, paged replay, atomic reconnect boundary. Both clients switch endpoints and behaviour does not change.2
4Byte-budget admission, one writer per connection, the acceptance barrier.3
5The stream context mints the id, messages are constructed with it, and the three progress frames go out across all four entry points.3
6Client turn model — keyed slots, retirement by id, and the render clock that makes streaming smooth.5
7Stop emitting the old delta event; freeze the old endpoint and announce its removal. Nothing decodes wrong — it simply stops being offered.6
Tests that do not exist yet

The current streaming test covers deltas being emitted and suppressed, and nothing else. Six are missing: retry against identity, reconnect against slot discard, replay against live interleave, ACP against a split-chunk secret, error-after-tokens against exactly one retirement, and a wedged connection against a publisher that must not block. All six fail today.

09How this was checked