Skip to content

feat(plugins): observable gateway token stream (on_stream_delta/segment/end hooks) - #65077

Closed
CocaKova wants to merge 1 commit into
NousResearch:mainfrom
CocaKova:pr/plugin-stream-hooks
Closed

feat(plugins): observable gateway token stream (on_stream_delta/segment/end hooks)#65077
CocaKova wants to merge 1 commit into
NousResearch:mainfrom
CocaKova:pr/plugin-stream-hooks

Conversation

@CocaKova

Copy link
Copy Markdown
Contributor

Adds three generic observer hooks fired by the gateway stream consumer as a turn streams to a chat platform:

hook when kwargs
on_stream_delta per token chat_id, metadata, message_id, delta
on_stream_segment segment boundary chat_id, metadata, message_id
on_stream_end stream complete chat_id, metadata, message_id, reason

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.

Why

This is the generic-surface follow-up to #57091, which was correctly closed under the "no third-party-product integrations in the core tree" policy. That bridge needed live token deltas, which the plugin surface did not expose — so it patched stream_consumer.py. Per AGENTS.md — "if a plugin needs a capability the framework doesn't expose, expand the generic plugin surface (new hook, new ctx method) — never hardcode plugin-specific logic into core" — the right fix is a generic hook, not a special-cased route. A standalone Keryx live-streaming plugin (published separately, installed under ~/.hermes/plugins/) is the concrete consumer, so this is not speculative infrastructure. It is equally usable by any streaming observer (logging, metrics, alternate transports).

Design / safety

  • Fired from the producer API (on_delta / on_segment_break / finish), after the existing queue writes — no internal worker-loop behavior changes.
  • Observers only — return values are ignored; a hook cannot alter or suppress delivery.
  • Per-token path is gated on has_hook(), so it costs nothing when no plugin is listening.
  • Callbacks run synchronously on the stream worker thread and must be non-blocking (documented); a raising callback is isolated by invoke_hook and cannot break token delivery.

Tests

tests/gateway/test_stream_observer_hooks.py: firing order + payloads, no delta event on empty/boundary text, gated no-op when unregistered (streaming path unaffected), callback-exception isolation, VALID_HOOKS membership. 5 passed; existing stream-consumer + suppression suites (174) unchanged. AGENTS.md plugin-hooks list updated.

🤖 Generated with Claude Code

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.
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for providing a generic plugin seam rather than an in-tree integration. The premise is current: live main's gateway/stream_consumer.py:323-384 only queues deltas, segment boundaries, and completion, and hermes_cli/plugins.py:135-215 has no on_stream_* hook names. The proposed use of the existing has_hook() and exception-isolating invoke_hook() behavior is consistent with the plugin surface.

Problems

  • The public hook references are not updated. website/docs/developer-guide/plugins/index.md:598-612 and website/docs/user-guide/features/hooks.md:379-397 list available plugin hooks, but the PR changes only AGENTS.md. Add the three hooks with their gateway-only scope, payloads, observer-only behavior, and synchronous/non-blocking requirement.

Suggested changes

  • Document on_stream_delta, on_stream_segment, and on_stream_end in both public tables; include chat_id, metadata, message_id, delta where applicable, and the done end reason.

Automated hermes-sweeper review.

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 16, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related competing streaming-hook design to #64317 and the #64161/#64182 expansion series. This PR invokes observers synchronously on the gateway stream worker; #64317 uses bounded asynchronous fan-out so plugin work cannot delay visible delivery. Please choose one delivery contract before merging either approach.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label 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.
@CocaKova
CocaKova force-pushed the pr/plugin-stream-hooks branch from 4e18b7c to 6637a1f Compare July 16, 2026 13:17
@CocaKova

Copy link
Copy Markdown
Contributor Author

Docs added in 6637a1f, recreated as a single commit on current main:

  • website/docs/developer-guide/plugins/index.md — three rows in the plugin-hooks summary table (fires-when, callback signature, returns), each marked Gateway only, plus a scope paragraph alongside the kanban one covering observer-only behavior, the after-queue-write firing point, the synchronous/non-blocking requirement, and the has_hook() gating.
  • website/docs/user-guide/features/hooks.md — three rows in the quick-reference table plus full per-hook sections (#on_stream_delta / #on_stream_segment / #on_stream_end) in the house format: callback signature, parameter tables (chat_id, metadata, message_id, delta where applicable, and the "done" end reason with unknown-values-are-terminal guidance), exact firing point in gateway/stream_consumer.py, a gateway-only note, and a warning callout on the synchronous/non-blocking requirement (enqueue and return, no inline network I/O).

The dev-guide table rows link to the new user-guide anchors, matching the existing hooks.

On the delivery-contract question raised in triage (vs #64317's bounded async fan-out): this PR deliberately keeps the same contract as every existing plugin hook — synchronous invoke_hook with exception isolation, gated on has_hook() so the per-token path is zero-cost when unused, fired after the queue write so observation can never alter delivery. A consumer that wants async fan-out can layer it in the plugin (enqueue and return), whereas a core async fan-out can't be undone by a plugin that needs strict ordering. Happy to adapt if the maintainers prefer the async contract.

Tests: tests/gateway/test_stream_observer_hooks.py (5) plus the stream-consumer, suppression, and streaming-defaults suites — 274 passed on current main.

@teknium1 teknium1 added the area/streaming Streaming responses: gateway delivery, provider wire label Jul 19, 2026
@teknium1

Copy link
Copy Markdown
Contributor

The streaming observer hook surface shipped on main via #84924 (salvage of #64317, @deaneeth — the tracker-named candidate on #64161, submitted before this PR): on_stream_start / on_stream_delta / on_stream_end / on_interim_message with a host-owned bounded queue keeping plugin callbacks off the token path, and reasoning deltas opt-in. Your design here converged on the same contract — thanks @CocaKova, and sorry this one sat long enough to collide. If the gateway/stream_consumer.py segmenting idea (delta→segment coalescing) still solves a problem the shipped hooks don't, that piece is welcome as a focused follow-up on top of the new surface. Closing as implemented on main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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