Skip to content

feat(gateway): opt-in SSE side-channel for live token streaming to external chat clients - #57091

Closed
CocaKova wants to merge 3 commits into
NousResearch:mainfrom
CocaKova:keryx-stream-side-channel
Closed

feat(gateway): opt-in SSE side-channel for live token streaming to external chat clients#57091
CocaKova wants to merge 3 commits into
NousResearch:mainfrom
CocaKova:keryx-stream-side-channel

Conversation

@CocaKova

@CocaKova CocaKova commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 for GET /keryx/stream?platform=…&chat_id=… on the existing API server (same Bearer auth via API_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 — mirrors delta / segment / stop events to the hub (thread-safe: call_soon_threadsafe onto 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.replace edit-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=1 lets Matrix fall back to throttled m.replace edits (driven by the standard streaming.edit_interval / buffer_threshold config) when the side-channel client is offline — off by default.

Wire protocol

event: delta    data: {"text": "…incremental tokens…"}
event: segment  data: {}     # text → tool → text boundary
event: stop     data: {}     # turn complete; server closes the channel
event: ping     data: {}     # 20s keepalive

Testing

Running in production on my gateway (Matrix + Keryx client): verified 200/text/event-stream with 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 files py_compile clean.

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_edits gates on subscriber presence, (platform, chat_id) keying isolates rooms, the Matrix FALLBACK_EDITS tier 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.replace history), reasoning blocks display correctly, and there are zero duplicate or stuck bubbles. The KERYX_STREAM_FALLBACK_EDITS=1 path 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

…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>
@CocaKova
CocaKova marked this pull request as draft July 2, 2026 13:17
@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery platform/matrix Matrix adapter (E2EE) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages P3 Low — cosmetic, nice to have labels Jul 2, 2026
@CocaKova

CocaKova commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

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>
@CocaKova
CocaKova marked this pull request as ready for review July 3, 2026 13:40
…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>
@teknium1

Copy link
Copy Markdown
Contributor

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 (gateway/keryx_stream.py, /keryx/stream, and KERYX_STREAM_FALLBACK_EDITS). The standing policy for third-party product integrations is to ship them as standalone plugins rather than carry their maintenance coupling in this repository.

  • The repository policy explicitly directs third-party integrations to standalone plugins installed under ~/.hermes/plugins/ or through pip entry points (AGENTS.md:126-135).
  • Current main already has an authenticated generic run-event SSE surface at gateway/platforms/api_server.py:4702-4751 (introduced by 57da1e1cee940b9bdb7372972c44a37ab0407872).
  • Please publish the Keryx bridge as a standalone plugin repository, where it can evolve with the released Android client and be promoted in #plugins-skills-and-skins.

This is an automated hermes-sweeper review.


Closed as not-planned per standing maintainer policy (in-tree-provider-integration). This is a design-direction decision, not a code-quality judgment — see the Contribution Rubric in AGENTS.md for what the project is looking for. If you believe this policy was misapplied to your change, comment here and a maintainer will take a look.

@teknium1 teknium1 closed this Jul 15, 2026
@teknium1 teknium1 added the sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) label Jul 15, 2026
@CocaKova

Copy link
Copy Markdown
Contributor Author

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 (GET /v1/runs/{run_id}/events) that a client bridge can consume without any in-tree coupling.

We'll republish it as a standalone plugin installed into ~/.hermes/plugins/ (registering through the existing discovery path, consuming that run-events SSE rather than a bespoke /keryx/stream), and promote it in #plugins-skills-and-skins. Thanks for the clear pointer to the right surface.

CocaKova added a commit to CocaKova/keryx-stream that referenced this pull request Jul 15, 2026
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.
@CocaKova
CocaKova deleted the keryx-stream-side-channel branch July 15, 2026 17:45
CocaKova added a commit to CocaKova/hermes-agent that referenced this pull request Jul 16, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:not-planned Sweeper: closed per standing maintainer policy (design direction) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants