feat(gateway): opt-in SSE side-channel for live token streaming to external chat clients - #57091
feat(gateway): opt-in SSE side-channel for live token streaming to external chat clients#57091CocaKova wants to merge 3 commits into
Conversation
…yx_stream)
Adds a transient, per-turn Server-Sent-Events side-channel so external chat
clients (born from the Keryx Android Matrix client) can render assistant
tokens live WITHOUT platform edit-streaming:
- gateway/keryx_stream.py (new): an in-process pub/sub hub keyed by
(platform, chat_id) plus the aiohttp handler for GET /keryx/stream on the
existing API server (same Bearer auth / API_SERVER_KEY, 20s keepalive
pings, bounded per-subscriber queues that drop rather than block the
agent's worker thread).
- gateway/stream_consumer.py: mirrors delta / segment-break / stop events to
the hub (thread-safe via call_soon_threadsafe), and while a subscriber is
attached suppresses interval/threshold platform edits for that chat — the
side-channel carries the tokens, the platform receives only the single
final committed message (this is the whole point for Matrix, where
m.replace edit-streaming bloats homeserver databases and can corrupt
client timelines).
- gateway/platforms/api_server.py: registers the route.
Default behaviour is unchanged: with no subscriber attached everything flows
exactly as before. An optional fallback tier (KERYX_STREAM_FALLBACK_EDITS=1)
lets Matrix use throttled m.replace edits when the side-channel client is
offline, driven by the standard streaming.edit_interval/buffer_threshold
config; it is off by default.
Wire protocol (one JSON object per data line):
event: delta data: {"text": "..."}
event: segment data: {}
event: stop data: {}
event: ping data: {}
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Currently working on several fixes for the message delivery bugs the sweeper picked up. |
Locks the behaviour contract the gateway depends on: stream events mirror in order to an attached subscriber; suppress_protocol_edits gates on subscriber presence; (platform, chat_id) keying isolates rooms; and with no subscriber the Matrix FALLBACK_EDITS tier drops to throttled m.replace. Also asserts the bounded per-subscriber queue drops on overflow rather than blocking the agent worker thread. Signed-off-by: Jonny Kovacs <jonathan.kovacs999@gmail.com>
…p tokens The side-channel wrote one SSE frame per token delta. When the model generates faster than the remote client drains the socket, the bounded per-subscriber asyncio.Queue backs up toward _QUEUE_MAX and _offer drops the overflow. A single dropped delta breaks the client's stream/commit reconciliation (the accumulated stream no longer byte-matches the committed message), surfacing as a duplicate/stuck message at end of turn — i.e. streaming visibly breaks once the brain is fast enough. drain_coalesced() merges whatever is already queued into as few frames as possible: consecutive deltas concatenate into one delta frame; segment/stop boundaries flush and pass through in order. This bounds the write rate to the client's drain rate so the queue never overflows, and is byte-exact because delta concatenation is associative — the coalesced stream is identical to the per-token stream. Adaptive: when the client keeps up, tokens still write individually. Adds two regression cases to the side-channel test file (a 1000-delta burst stays byte-exact through a stop; segment boundaries are preserved in order). Signed-off-by: Jonny Kovacs <jonathan.kovacs999@gmail.com>
|
Thanks for the concrete implementation and the focused queue/ordering coverage. Automated hermes-sweeper review: this is a Keryx-specific integration embedded in the gateway core (
This is an automated hermes-sweeper review. Closed as not-planned per standing maintainer policy ( |
|
Agreed — the policy applies here; no misapplication. The Keryx bridge is a product-specific integration and doesn't belong in the gateway core, and main already exposes an authenticated generic surface ( We'll republish it as a standalone plugin installed into |
Replaces the in-tree gateway patch (install.py + monolithic keryx_stream.py) that was closed upstream (NousResearch/hermes-agent#57091 — "no third-party integrations in the core tree"). This version uses only the public plugin surface: - register(ctx) subscribes to the generic stream observer hooks on_stream_delta / on_stream_segment / on_stream_end (NousResearch/hermes-agent#65077) and mirrors each turn's tokens to an in-process hub. - Serves its own bearer-authed SSE side-channel (GET /keryx/stream) plus toolset view/toggle (GET/PUT /keryx/toolsets) on its own daemon thread — adds no gateway route, patches no core file. - Byte-exact delta coalescing preserved from the prior version so a fast model can't overflow a subscriber queue and break the client's stream/commit match. - Config in config.yaml (keryx_stream block); bearer token from env (KERYX_STREAM_TOKEN / API_SERVER_KEY). Toolset locks moved from KERYX_TOOLSETS_* env vars to config.yaml lists, per the non-secret-config rule. Out-of-scope routes from the old monolith (kanban / pet / skills / prune) are dropped from this streaming-focused plugin. plugin.yaml + pyproject.toml entry point for both directory and pip installs. 23 tests, ruff clean.
…gment/end hooks Adds three generic observer hooks fired by the gateway stream consumer as a turn streams to a chat platform: - on_stream_delta(chat_id, metadata, message_id, delta) — per token - on_stream_segment(chat_id, metadata, message_id) — segment boundary - on_stream_end(chat_id, metadata, message_id, reason) — stream complete They let a plugin observe the live token stream — mirror it to an external side-channel/SSE, drive streaming metrics, bridge an alternate client — entirely from the standard plugin surface, without patching core streaming. Observers only (return values ignored). Callbacks run synchronously on the stream worker thread and must be non-blocking; the per-token path is gated on has_hook() so it costs nothing when no plugin is listening. Fired from the producer API (on_delta / on_segment_break / finish) so no internal worker-loop behavior changes — the hooks fire after the existing queue writes. Concrete consumer: a standalone Keryx live-streaming plugin (published separately per the third-party-integration policy) mirrors deltas to its own SSE side-channel — the generic-surface replacement for the in-tree bridge closed in NousResearch#57091, per AGENTS.md ("if a plugin needs a capability the framework doesn't expose, expand the generic plugin surface, never hardcode plugin-specific logic into core"). Documented in both public hook references — the plugin-hooks table in website/docs/developer-guide/plugins/index.md and full per-hook sections in website/docs/user-guide/features/hooks.md (gateway-only scope, payloads incl. the "done" end reason, observer-only, synchronous/non-blocking requirement) — plus the AGENTS.md plugin-hooks list. tests/gateway/test_stream_observer_hooks.py: firing order + payloads, no delta on empty/boundary text, gated no-op when unregistered (streaming unaffected), callback-exception isolation, VALID_HOOKS membership. 5 passed; stream-consumer, suppression, and streaming-defaults suites (274 total) green on current main.
What
An opt-in, transient Server-Sent-Events side-channel that lets an external chat client render assistant tokens live without platform edit-streaming. Built for (and shipping with) Keryx, an Android Matrix client that acts as a command interface for a Hermes agent — but the mechanism is platform-agnostic.
gateway/keryx_stream.py(new) — in-process pub/sub hub keyed by(platform, chat_id)+ the aiohttp handler forGET /keryx/stream?platform=…&chat_id=…on the existing API server (same Bearer auth viaAPI_SERVER_KEY, 20 s keepalive pings, bounded per-subscriber queues that drop on overflow rather than ever blocking the agent's worker thread).gateway/stream_consumer.py— mirrorsdelta/segment/stopevents to the hub (thread-safe:call_soon_threadsafeonto each subscriber's loop), and while a subscriber is attached, suppresses interval/threshold platform edits for that chat: the side-channel carries the tokens and the platform receives only the single final committed message.gateway/platforms/api_server.py— one route registration, wrapped in try/except so the API server is unaffected if the module is absent.Why
Matrix is the motivating case:
m.replaceedit-streaming bloats homeserver databases (every partial is a persisted event) and heavy edit-streams can corrupt client timelines (we hit Trixnity's "loop in timeline generation" in the wild). With this side-channel a client gets full live token rendering while the room's event history stays exactly one message per turn. Clients subscribe right before sending a command and the switch is evaluated per flush, so attachment mid-turn behaves correctly.Default behaviour is unchanged
With no subscriber attached, every path flows exactly as today. The only behavioural knob is opt-in:
KERYX_STREAM_FALLBACK_EDITS=1lets Matrix fall back to throttledm.replaceedits (driven by the standardstreaming.edit_interval/buffer_thresholdconfig) when the side-channel client is offline — off by default.Wire protocol
Testing
Running in production on my gateway (Matrix + Keryx client): verified 200/
text/event-streamwith valid key, 401 without, live delta mirroring during agent turns, single final Matrix commit per turn with a subscriber attached, and no change to/health,/v1/models, or existing chat-completions routes. All three touched filespy_compileclean.Automated regression test (
tests/gateway/test_keryx_stream_side_channel.py, 3 cases, all green in the Hermes venv): asserts events mirror in order to an attached subscriber,suppress_protocol_editsgates on subscriber presence,(platform, chat_id)keying isolates rooms, the MatrixFALLBACK_EDITStier returns the throttled path with no subscriber, and the bounded per-subscriber queue drops on overflow instead of blocking the worker thread.Update — 2026-07-03: verified end-to-end from a real client. With the companion reasoning-display fix (#57693) applied, I've exercised the full path live from the Keryx Android client against a production gateway across many turns: tokens render live off the side-channel, the Matrix room commits exactly one final message per turn (no
m.replacehistory), reasoning blocks display correctly, and there are zero duplicate or stuck bubbles. TheKERYX_STREAM_FALLBACK_EDITS=1path still degrades cleanly to throttled edits when no subscriber is attached.Happy to rename the module/route to something more generic (e.g.
client_stream) if you'd prefer — kept the shipping name so the released client and this PR match.🤖 Generated with Claude Code